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.
- package/LICENSE +21 -21
- package/README.md +120 -118
- package/docs/architecture.md +401 -401
- package/docs/aspect-ratio-presets.md +72 -72
- package/docs/releasing.md +19 -19
- package/docs/usage.md +1193 -1193
- package/package.json +52 -52
- package/requirements-otio.txt +1 -1
- package/src/capabilities.mjs +307 -307
- package/src/cli.mjs +646 -646
- package/src/doctor.mjs +133 -133
- package/src/edits/analysis-to-edl.mjs +43 -43
- package/src/edits/detect-silence.mjs +51 -51
- package/src/edits/find-mistake-candidates.mjs +42 -42
- package/src/edits/plan-shot-placements.mjs +105 -105
- package/src/edits/plan-silence-cuts.mjs +49 -49
- package/src/edits/retime-transcript.mjs +105 -105
- package/src/files.mjs +80 -80
- package/src/index.mjs +44 -44
- package/src/interchange/otio.mjs +204 -204
- package/src/interchange/otio_bridge.py +521 -521
- package/src/media/analysis-provider.mjs +75 -75
- package/src/media/compatibility.mjs +45 -45
- package/src/media/ffmpeg-capabilities.mjs +129 -129
- package/src/media/frame-rate.mjs +58 -58
- package/src/media/frame-time.mjs +22 -22
- package/src/media/preview-assets.mjs +350 -350
- package/src/media/probe.mjs +149 -149
- package/src/media/render-cuts.mjs +90 -90
- package/src/media/scene-detection.mjs +107 -107
- package/src/media/scene-review.mjs +205 -205
- package/src/project/assets.mjs +1194 -1194
- package/src/project/create.mjs +100 -100
- package/src/project/presets.mjs +44 -44
- package/src/render/checkpoints.mjs +192 -192
- package/src/render/formats.mjs +163 -163
- package/src/render/hyperframes.mjs +590 -590
- package/src/render/process.mjs +111 -111
- package/src/render/progress.mjs +46 -46
- package/src/render/timeline-control.mjs +53 -53
- package/src/timeline/assemble.mjs +119 -119
- package/src/timeline/audio-effects.mjs +75 -75
- package/src/timeline/audio-mastering.mjs +236 -236
- package/src/timeline/audio-mix.mjs +105 -105
- package/src/timeline/audio-track-effects.mjs +139 -139
- package/src/timeline/audio-volume.mjs +63 -63
- package/src/timeline/background.mjs +8 -8
- package/src/timeline/blend-mode.mjs +34 -34
- package/src/timeline/caption-layouts.mjs +62 -61
- package/src/timeline/captions.mjs +80 -80
- package/src/timeline/edit.mjs +965 -965
- package/src/timeline/fingerprint.mjs +6 -6
- package/src/timeline/from-edl.mjs +31 -31
- package/src/timeline/history-store.mjs +281 -281
- package/src/timeline/history.mjs +69 -69
- package/src/timeline/inspect.mjs +117 -117
- package/src/timeline/keyframes.mjs +158 -158
- package/src/timeline/layers.mjs +18 -18
- package/src/timeline/mask.mjs +53 -53
- package/src/timeline/project.mjs +52 -52
- package/src/timeline/render.mjs +759 -759
- package/src/timeline/sidechain-render.mjs +209 -209
- package/src/timeline/source-fit.mjs +15 -15
- package/src/timeline/speech-bubble.mjs +26 -26
- package/src/timeline/speed-ramp.mjs +188 -188
- package/src/timeline/track-state.mjs +22 -22
- package/src/timeline/transition.mjs +34 -34
- package/src/timeline/validate.mjs +322 -322
- package/src/transcript/corrections.mjs +21 -21
- package/src/transcript/normalize.mjs +17 -17
- package/src/transcript/provider.mjs +42 -42
- package/src/transcript/subtitles.mjs +57 -57
- 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
|
+
}
|