aivee 0.1.0-alpha.0 → 0.1.0-dev.59872e1
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/CHANGELOG.md +34 -0
- 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/media/probe.mjs
CHANGED
|
@@ -1,149 +1,149 @@
|
|
|
1
|
-
import { existsSync, statSync } from 'node:fs';
|
|
2
|
-
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import { resolve } from 'node:path';
|
|
4
|
-
import { runProcess } from '../render/process.mjs';
|
|
5
|
-
|
|
6
|
-
export function probeMedia(inputPath, { requireVideo = true, timeoutMs } = {}) {
|
|
7
|
-
if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)) {
|
|
8
|
-
throw new TypeError('ffprobe timeoutMs must be a positive safe integer.');
|
|
9
|
-
}
|
|
10
|
-
const path = resolve(inputPath);
|
|
11
|
-
if (!existsSync(path) || !statSync(path).isFile()) throw new Error(`Media file not found: ${path}`);
|
|
12
|
-
const result = spawnSync('ffprobe', [
|
|
13
|
-
'-v', 'error', '-show_format', '-show_streams', '-of', 'json', path,
|
|
14
|
-
], { encoding: 'utf8', windowsHide: true, ...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}) });
|
|
15
|
-
if (result.error || result.status !== 0) {
|
|
16
|
-
throw new Error(`ffprobe failed: ${result.error?.message ?? result.stderr.trim()}`);
|
|
17
|
-
}
|
|
18
|
-
return parseProbeOutput(result.stdout, path, { requireVideo });
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function probeMediaAsync(inputPath, { requireVideo = true, signal } = {}) {
|
|
22
|
-
validateAbortSignal(signal);
|
|
23
|
-
const path = resolve(inputPath);
|
|
24
|
-
if (!existsSync(path) || !statSync(path).isFile()) throw new Error(`Media file not found: ${path}`);
|
|
25
|
-
const result = await runProcess('ffprobe', [
|
|
26
|
-
'-v', 'error', '-show_format', '-show_streams', '-of', 'json', path,
|
|
27
|
-
], { signal, stdio: 'capture' });
|
|
28
|
-
return parseProbeOutput(result.stdout, path, { requireVideo });
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function validateAbortSignal(signal) {
|
|
32
|
-
if (signal !== undefined && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function')) {
|
|
33
|
-
throw new TypeError('ffprobe signal must be an AbortSignal.');
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function parseProbeOutput(output, path = '', { requireVideo = true } = {}) {
|
|
38
|
-
let info;
|
|
39
|
-
try {
|
|
40
|
-
info = JSON.parse(output);
|
|
41
|
-
} catch (error) {
|
|
42
|
-
throw new TypeError(`Invalid ffprobe JSON: ${error.message}`);
|
|
43
|
-
}
|
|
44
|
-
const streams = Array.isArray(info.streams) ? info.streams : [];
|
|
45
|
-
const formatDuration = parseOptionalNumber(info.format?.duration);
|
|
46
|
-
const streamDuration = streams.reduce((duration, stream) =>
|
|
47
|
-
Math.max(duration, parseStreamDuration(stream)), 0);
|
|
48
|
-
const durationSeconds = Number.isFinite(formatDuration) && formatDuration > 0
|
|
49
|
-
? formatDuration
|
|
50
|
-
: streamDuration;
|
|
51
|
-
const videoStreams = streams.filter((stream) => stream.codec_type === 'video').map((stream, index) => ({
|
|
52
|
-
index,
|
|
53
|
-
streamIndex: Number.isSafeInteger(stream.index) ? stream.index : index,
|
|
54
|
-
codec: stream.codec_name ?? null,
|
|
55
|
-
width: Number(stream.width) || null,
|
|
56
|
-
height: Number(stream.height) || null,
|
|
57
|
-
pixelFormat: stream.pix_fmt ?? null,
|
|
58
|
-
frameRate: parseFrameRate(stream.avg_frame_rate || stream.r_frame_rate),
|
|
59
|
-
timeBase: stream.time_base ?? null,
|
|
60
|
-
startSeconds: parseOptionalNumber(stream.start_time),
|
|
61
|
-
durationSeconds: parseStreamDuration(stream) || null,
|
|
62
|
-
}));
|
|
63
|
-
const video = streams.find((stream) => stream.codec_type === 'video');
|
|
64
|
-
const audioStreams = streams.filter((stream) => stream.codec_type === 'audio').map((stream, index) => ({
|
|
65
|
-
index,
|
|
66
|
-
streamIndex: Number.isSafeInteger(stream.index) ? stream.index : index,
|
|
67
|
-
codec: stream.codec_name ?? null,
|
|
68
|
-
channels: Number(stream.channels) || null,
|
|
69
|
-
channelLayout: stream.channel_layout ?? null,
|
|
70
|
-
sampleRate: Number(stream.sample_rate) || null,
|
|
71
|
-
startSeconds: parseOptionalNumber(stream.start_time),
|
|
72
|
-
durationSeconds: parseStreamDuration(stream) || null,
|
|
73
|
-
timeBase: stream.time_base ?? null,
|
|
74
|
-
language: typeof stream.tags?.language === 'string' ? stream.tags.language : null,
|
|
75
|
-
title: typeof stream.tags?.title === 'string' ? stream.tags.title : null,
|
|
76
|
-
}));
|
|
77
|
-
const audio = audioStreams[0];
|
|
78
|
-
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
79
|
-
throw new TypeError('Media must have a finite positive duration.');
|
|
80
|
-
}
|
|
81
|
-
if (requireVideo && !video) throw new TypeError('Media must contain a video stream.');
|
|
82
|
-
if (!video && !audio) throw new TypeError('Media must contain a video or audio stream.');
|
|
83
|
-
return {
|
|
84
|
-
path,
|
|
85
|
-
durationSeconds,
|
|
86
|
-
formatName: typeof info.format?.format_name === 'string' ? info.format.format_name : null,
|
|
87
|
-
videoStreams,
|
|
88
|
-
width: Number(video?.width) || null,
|
|
89
|
-
height: Number(video?.height) || null,
|
|
90
|
-
frameRate: parseFrameRate(video?.avg_frame_rate || video?.r_frame_rate),
|
|
91
|
-
nominalFrameRate: parseFrameRate(video?.r_frame_rate),
|
|
92
|
-
hasAudio: Boolean(audio),
|
|
93
|
-
audioChannels: audio?.channels ?? null,
|
|
94
|
-
audioChannelLayout: audio?.channelLayout ?? null,
|
|
95
|
-
audioStreams,
|
|
96
|
-
videoCodec: video?.codec_name ?? null,
|
|
97
|
-
pixelFormat: video?.pix_fmt ?? null,
|
|
98
|
-
audioCodec: audio?.codec ?? null,
|
|
99
|
-
colorSpace: video?.color_space ?? null,
|
|
100
|
-
colorTransfer: video?.color_transfer ?? null,
|
|
101
|
-
colorPrimaries: video?.color_primaries ?? null,
|
|
102
|
-
colorRange: video?.color_range ?? null,
|
|
103
|
-
streamTiming: {
|
|
104
|
-
containerStartSeconds: parseOptionalNumber(info.format?.start_time),
|
|
105
|
-
videoStartSeconds: parseOptionalNumber(video?.start_time),
|
|
106
|
-
videoDurationSeconds: parseStreamDuration(video) || null,
|
|
107
|
-
audioStartSeconds: audio?.startSeconds ?? null,
|
|
108
|
-
audioDurationSeconds: audio?.durationSeconds ?? null,
|
|
109
|
-
videoTimeBase: video?.time_base ?? null,
|
|
110
|
-
audioTimeBase: audio?.timeBase ?? null,
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Normalize stored video metadata to ordinal indexes plus FFmpeg-global indexes. */
|
|
116
|
-
export function normalizeVideoStreamMetadata(streams) {
|
|
117
|
-
if (!Array.isArray(streams)) return streams;
|
|
118
|
-
return streams.map((stream, index) => {
|
|
119
|
-
if (!stream || typeof stream !== 'object' || Array.isArray(stream)) return stream;
|
|
120
|
-
return { ...stream, index, streamIndex: stream.streamIndex ?? stream.index ?? index };
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function parseOptionalNumber(value) {
|
|
125
|
-
if (value === undefined || value === null || value === 'N/A') return null;
|
|
126
|
-
const parsed = Number(value);
|
|
127
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function parseStreamDuration(stream) {
|
|
131
|
-
if (!stream || typeof stream !== 'object') return 0;
|
|
132
|
-
const duration = parseOptionalNumber(stream.duration);
|
|
133
|
-
if (duration !== null && duration > 0) return duration;
|
|
134
|
-
const ticks = parseOptionalNumber(stream.duration_ts);
|
|
135
|
-
const [numerator, denominator] = typeof stream.time_base === 'string'
|
|
136
|
-
? stream.time_base.split('/').map(Number)
|
|
137
|
-
: [];
|
|
138
|
-
if (!Number.isFinite(ticks) || ticks <= 0 || !Number.isFinite(numerator)
|
|
139
|
-
|| !Number.isFinite(denominator) || numerator <= 0 || denominator <= 0) return 0;
|
|
140
|
-
return ticks * numerator / denominator;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function parseFrameRate(value) {
|
|
144
|
-
if (typeof value !== 'string') return null;
|
|
145
|
-
const [numerator, denominator] = value.split('/').map(Number);
|
|
146
|
-
return Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0
|
|
147
|
-
? numerator / denominator
|
|
148
|
-
: null;
|
|
149
|
-
}
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { runProcess } from '../render/process.mjs';
|
|
5
|
+
|
|
6
|
+
export function probeMedia(inputPath, { requireVideo = true, timeoutMs } = {}) {
|
|
7
|
+
if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)) {
|
|
8
|
+
throw new TypeError('ffprobe timeoutMs must be a positive safe integer.');
|
|
9
|
+
}
|
|
10
|
+
const path = resolve(inputPath);
|
|
11
|
+
if (!existsSync(path) || !statSync(path).isFile()) throw new Error(`Media file not found: ${path}`);
|
|
12
|
+
const result = spawnSync('ffprobe', [
|
|
13
|
+
'-v', 'error', '-show_format', '-show_streams', '-of', 'json', path,
|
|
14
|
+
], { encoding: 'utf8', windowsHide: true, ...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}) });
|
|
15
|
+
if (result.error || result.status !== 0) {
|
|
16
|
+
throw new Error(`ffprobe failed: ${result.error?.message ?? result.stderr.trim()}`);
|
|
17
|
+
}
|
|
18
|
+
return parseProbeOutput(result.stdout, path, { requireVideo });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function probeMediaAsync(inputPath, { requireVideo = true, signal } = {}) {
|
|
22
|
+
validateAbortSignal(signal);
|
|
23
|
+
const path = resolve(inputPath);
|
|
24
|
+
if (!existsSync(path) || !statSync(path).isFile()) throw new Error(`Media file not found: ${path}`);
|
|
25
|
+
const result = await runProcess('ffprobe', [
|
|
26
|
+
'-v', 'error', '-show_format', '-show_streams', '-of', 'json', path,
|
|
27
|
+
], { signal, stdio: 'capture' });
|
|
28
|
+
return parseProbeOutput(result.stdout, path, { requireVideo });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateAbortSignal(signal) {
|
|
32
|
+
if (signal !== undefined && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function')) {
|
|
33
|
+
throw new TypeError('ffprobe signal must be an AbortSignal.');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseProbeOutput(output, path = '', { requireVideo = true } = {}) {
|
|
38
|
+
let info;
|
|
39
|
+
try {
|
|
40
|
+
info = JSON.parse(output);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw new TypeError(`Invalid ffprobe JSON: ${error.message}`);
|
|
43
|
+
}
|
|
44
|
+
const streams = Array.isArray(info.streams) ? info.streams : [];
|
|
45
|
+
const formatDuration = parseOptionalNumber(info.format?.duration);
|
|
46
|
+
const streamDuration = streams.reduce((duration, stream) =>
|
|
47
|
+
Math.max(duration, parseStreamDuration(stream)), 0);
|
|
48
|
+
const durationSeconds = Number.isFinite(formatDuration) && formatDuration > 0
|
|
49
|
+
? formatDuration
|
|
50
|
+
: streamDuration;
|
|
51
|
+
const videoStreams = streams.filter((stream) => stream.codec_type === 'video').map((stream, index) => ({
|
|
52
|
+
index,
|
|
53
|
+
streamIndex: Number.isSafeInteger(stream.index) ? stream.index : index,
|
|
54
|
+
codec: stream.codec_name ?? null,
|
|
55
|
+
width: Number(stream.width) || null,
|
|
56
|
+
height: Number(stream.height) || null,
|
|
57
|
+
pixelFormat: stream.pix_fmt ?? null,
|
|
58
|
+
frameRate: parseFrameRate(stream.avg_frame_rate || stream.r_frame_rate),
|
|
59
|
+
timeBase: stream.time_base ?? null,
|
|
60
|
+
startSeconds: parseOptionalNumber(stream.start_time),
|
|
61
|
+
durationSeconds: parseStreamDuration(stream) || null,
|
|
62
|
+
}));
|
|
63
|
+
const video = streams.find((stream) => stream.codec_type === 'video');
|
|
64
|
+
const audioStreams = streams.filter((stream) => stream.codec_type === 'audio').map((stream, index) => ({
|
|
65
|
+
index,
|
|
66
|
+
streamIndex: Number.isSafeInteger(stream.index) ? stream.index : index,
|
|
67
|
+
codec: stream.codec_name ?? null,
|
|
68
|
+
channels: Number(stream.channels) || null,
|
|
69
|
+
channelLayout: stream.channel_layout ?? null,
|
|
70
|
+
sampleRate: Number(stream.sample_rate) || null,
|
|
71
|
+
startSeconds: parseOptionalNumber(stream.start_time),
|
|
72
|
+
durationSeconds: parseStreamDuration(stream) || null,
|
|
73
|
+
timeBase: stream.time_base ?? null,
|
|
74
|
+
language: typeof stream.tags?.language === 'string' ? stream.tags.language : null,
|
|
75
|
+
title: typeof stream.tags?.title === 'string' ? stream.tags.title : null,
|
|
76
|
+
}));
|
|
77
|
+
const audio = audioStreams[0];
|
|
78
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
79
|
+
throw new TypeError('Media must have a finite positive duration.');
|
|
80
|
+
}
|
|
81
|
+
if (requireVideo && !video) throw new TypeError('Media must contain a video stream.');
|
|
82
|
+
if (!video && !audio) throw new TypeError('Media must contain a video or audio stream.');
|
|
83
|
+
return {
|
|
84
|
+
path,
|
|
85
|
+
durationSeconds,
|
|
86
|
+
formatName: typeof info.format?.format_name === 'string' ? info.format.format_name : null,
|
|
87
|
+
videoStreams,
|
|
88
|
+
width: Number(video?.width) || null,
|
|
89
|
+
height: Number(video?.height) || null,
|
|
90
|
+
frameRate: parseFrameRate(video?.avg_frame_rate || video?.r_frame_rate),
|
|
91
|
+
nominalFrameRate: parseFrameRate(video?.r_frame_rate),
|
|
92
|
+
hasAudio: Boolean(audio),
|
|
93
|
+
audioChannels: audio?.channels ?? null,
|
|
94
|
+
audioChannelLayout: audio?.channelLayout ?? null,
|
|
95
|
+
audioStreams,
|
|
96
|
+
videoCodec: video?.codec_name ?? null,
|
|
97
|
+
pixelFormat: video?.pix_fmt ?? null,
|
|
98
|
+
audioCodec: audio?.codec ?? null,
|
|
99
|
+
colorSpace: video?.color_space ?? null,
|
|
100
|
+
colorTransfer: video?.color_transfer ?? null,
|
|
101
|
+
colorPrimaries: video?.color_primaries ?? null,
|
|
102
|
+
colorRange: video?.color_range ?? null,
|
|
103
|
+
streamTiming: {
|
|
104
|
+
containerStartSeconds: parseOptionalNumber(info.format?.start_time),
|
|
105
|
+
videoStartSeconds: parseOptionalNumber(video?.start_time),
|
|
106
|
+
videoDurationSeconds: parseStreamDuration(video) || null,
|
|
107
|
+
audioStartSeconds: audio?.startSeconds ?? null,
|
|
108
|
+
audioDurationSeconds: audio?.durationSeconds ?? null,
|
|
109
|
+
videoTimeBase: video?.time_base ?? null,
|
|
110
|
+
audioTimeBase: audio?.timeBase ?? null,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Normalize stored video metadata to ordinal indexes plus FFmpeg-global indexes. */
|
|
116
|
+
export function normalizeVideoStreamMetadata(streams) {
|
|
117
|
+
if (!Array.isArray(streams)) return streams;
|
|
118
|
+
return streams.map((stream, index) => {
|
|
119
|
+
if (!stream || typeof stream !== 'object' || Array.isArray(stream)) return stream;
|
|
120
|
+
return { ...stream, index, streamIndex: stream.streamIndex ?? stream.index ?? index };
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseOptionalNumber(value) {
|
|
125
|
+
if (value === undefined || value === null || value === 'N/A') return null;
|
|
126
|
+
const parsed = Number(value);
|
|
127
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseStreamDuration(stream) {
|
|
131
|
+
if (!stream || typeof stream !== 'object') return 0;
|
|
132
|
+
const duration = parseOptionalNumber(stream.duration);
|
|
133
|
+
if (duration !== null && duration > 0) return duration;
|
|
134
|
+
const ticks = parseOptionalNumber(stream.duration_ts);
|
|
135
|
+
const [numerator, denominator] = typeof stream.time_base === 'string'
|
|
136
|
+
? stream.time_base.split('/').map(Number)
|
|
137
|
+
: [];
|
|
138
|
+
if (!Number.isFinite(ticks) || ticks <= 0 || !Number.isFinite(numerator)
|
|
139
|
+
|| !Number.isFinite(denominator) || numerator <= 0 || denominator <= 0) return 0;
|
|
140
|
+
return ticks * numerator / denominator;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseFrameRate(value) {
|
|
144
|
+
if (typeof value !== 'string') return null;
|
|
145
|
+
const [numerator, denominator] = value.split('/').map(Number);
|
|
146
|
+
return Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0
|
|
147
|
+
? numerator / denominator
|
|
148
|
+
: null;
|
|
149
|
+
}
|
|
@@ -1,90 +1,90 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { basename, dirname, extname, resolve } from 'node:path';
|
|
4
|
-
import { spawnSync } from 'node:child_process';
|
|
5
|
-
import { assertEdlApproved, validateEdl } from '../edits/retime-transcript.mjs';
|
|
6
|
-
import { probeMedia } from './probe.mjs';
|
|
7
|
-
|
|
8
|
-
export function renderCuts(inputPath, edl, outputPath) {
|
|
9
|
-
const input = resolve(inputPath);
|
|
10
|
-
const output = resolve(outputPath);
|
|
11
|
-
if (input === output) throw new Error('Output path must differ from the source video.');
|
|
12
|
-
if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
|
|
13
|
-
if (extname(output).toLowerCase() !== '.mp4') throw new Error('Cut output must use the .mp4 extension.');
|
|
14
|
-
|
|
15
|
-
const media = probeMedia(input);
|
|
16
|
-
validateEdl(edl, media.durationSeconds);
|
|
17
|
-
assertEdlApproved(edl);
|
|
18
|
-
const keepRanges = getKeepRanges(media.durationSeconds, edl.cuts);
|
|
19
|
-
if (!keepRanges.length) throw new Error('EDL removes the entire source video.');
|
|
20
|
-
|
|
21
|
-
const outputDir = dirname(output);
|
|
22
|
-
mkdirSync(outputDir, { recursive: true });
|
|
23
|
-
const token = randomUUID();
|
|
24
|
-
const filterPath = resolve(outputDir, `.${basename(output)}.${token}.filter.txt`);
|
|
25
|
-
const temporaryOutput = resolve(outputDir, `.${basename(output, '.mp4')}.${token}.tmp.mp4`);
|
|
26
|
-
const filterGraph = createFilterGraph(keepRanges, media.hasAudio);
|
|
27
|
-
writeFileSync(filterPath, filterGraph, { flag: 'wx' });
|
|
28
|
-
let outputCreated = false;
|
|
29
|
-
try {
|
|
30
|
-
const args = ['-nostdin', '-hide_banner', '-v', 'error', '-n', '-i', input,
|
|
31
|
-
'-/filter_complex', filterPath, '-map', '[v]'];
|
|
32
|
-
if (media.hasAudio) args.push('-map', '[a]');
|
|
33
|
-
else args.push('-an');
|
|
34
|
-
args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-pix_fmt', 'yuv420p');
|
|
35
|
-
if (media.hasAudio) args.push('-c:a', 'aac', '-b:a', '192k');
|
|
36
|
-
args.push('-movflags', '+faststart', temporaryOutput);
|
|
37
|
-
let result = spawnSync('ffmpeg', args, { encoding: 'utf8', windowsHide: true });
|
|
38
|
-
if (result.status !== 0 && /unrecognized option ['"]?\/?filter_complex|option not found/i.test(result.stderr)) {
|
|
39
|
-
const optionIndex = args.indexOf('-/filter_complex');
|
|
40
|
-
args.splice(optionIndex, 2, '-filter_complex', filterGraph);
|
|
41
|
-
result = spawnSync('ffmpeg', args, { encoding: 'utf8', windowsHide: true });
|
|
42
|
-
}
|
|
43
|
-
if (result.error || result.status !== 0) {
|
|
44
|
-
throw new Error(`ffmpeg failed: ${result.error?.message ?? result.stderr.trim()}`);
|
|
45
|
-
}
|
|
46
|
-
closeSync(openSync(output, 'wx'));
|
|
47
|
-
outputCreated = true;
|
|
48
|
-
copyFileSync(temporaryOutput, output);
|
|
49
|
-
const rendered = probeMedia(output);
|
|
50
|
-
const expectedDuration = keepRanges.reduce((sum, range) => sum + range.end - range.start, 0);
|
|
51
|
-
if (Math.abs(rendered.durationSeconds - expectedDuration) > Math.max(0.15, 1 / (media.frameRate || 30))) {
|
|
52
|
-
throw new Error(`Rendered duration ${rendered.durationSeconds.toFixed(3)}s does not match expected ${expectedDuration.toFixed(3)}s.`);
|
|
53
|
-
}
|
|
54
|
-
if (media.hasAudio !== rendered.hasAudio) throw new Error('Rendered audio stream does not match the source.');
|
|
55
|
-
return { input, output, keepRanges, durationSeconds: keepRanges.reduce((sum, range) => sum + range.end - range.start, 0) };
|
|
56
|
-
} catch (error) {
|
|
57
|
-
if (outputCreated && existsSync(output)) unlinkSync(output);
|
|
58
|
-
throw error;
|
|
59
|
-
} finally {
|
|
60
|
-
rmSync(filterPath, { force: true });
|
|
61
|
-
rmSync(temporaryOutput, { force: true });
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function getKeepRanges(durationSeconds, cuts) {
|
|
66
|
-
const ranges = [];
|
|
67
|
-
let cursor = 0;
|
|
68
|
-
for (const cut of cuts) {
|
|
69
|
-
if (cut.sourceStart > cursor) ranges.push({ start: cursor, end: cut.sourceStart });
|
|
70
|
-
cursor = cut.sourceEnd;
|
|
71
|
-
}
|
|
72
|
-
if (cursor < durationSeconds) ranges.push({ start: cursor, end: durationSeconds });
|
|
73
|
-
return ranges;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function createFilterGraph(ranges, hasAudio) {
|
|
77
|
-
const lines = [];
|
|
78
|
-
for (const [index, range] of ranges.entries()) {
|
|
79
|
-
lines.push(`[0:v]trim=start=${range.start}:end=${range.end},setpts=PTS-STARTPTS[v${index}]`);
|
|
80
|
-
if (hasAudio) lines.push(`[0:a]atrim=start=${range.start}:end=${range.end},asetpts=PTS-STARTPTS[a${index}]`);
|
|
81
|
-
}
|
|
82
|
-
const videoInputs = ranges.map((_, index) => `[v${index}]`).join('');
|
|
83
|
-
if (hasAudio) {
|
|
84
|
-
const inputs = ranges.map((_, index) => `[v${index}][a${index}]`).join('');
|
|
85
|
-
lines.push(`${inputs}concat=n=${ranges.length}:v=1:a=1[v][a]`);
|
|
86
|
-
} else {
|
|
87
|
-
lines.push(`${videoInputs}concat=n=${ranges.length}:v=1:a=0[v]`);
|
|
88
|
-
}
|
|
89
|
-
return `${lines.join(';\n')}\n`;
|
|
90
|
-
}
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, extname, resolve } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { assertEdlApproved, validateEdl } from '../edits/retime-transcript.mjs';
|
|
6
|
+
import { probeMedia } from './probe.mjs';
|
|
7
|
+
|
|
8
|
+
export function renderCuts(inputPath, edl, outputPath) {
|
|
9
|
+
const input = resolve(inputPath);
|
|
10
|
+
const output = resolve(outputPath);
|
|
11
|
+
if (input === output) throw new Error('Output path must differ from the source video.');
|
|
12
|
+
if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
|
|
13
|
+
if (extname(output).toLowerCase() !== '.mp4') throw new Error('Cut output must use the .mp4 extension.');
|
|
14
|
+
|
|
15
|
+
const media = probeMedia(input);
|
|
16
|
+
validateEdl(edl, media.durationSeconds);
|
|
17
|
+
assertEdlApproved(edl);
|
|
18
|
+
const keepRanges = getKeepRanges(media.durationSeconds, edl.cuts);
|
|
19
|
+
if (!keepRanges.length) throw new Error('EDL removes the entire source video.');
|
|
20
|
+
|
|
21
|
+
const outputDir = dirname(output);
|
|
22
|
+
mkdirSync(outputDir, { recursive: true });
|
|
23
|
+
const token = randomUUID();
|
|
24
|
+
const filterPath = resolve(outputDir, `.${basename(output)}.${token}.filter.txt`);
|
|
25
|
+
const temporaryOutput = resolve(outputDir, `.${basename(output, '.mp4')}.${token}.tmp.mp4`);
|
|
26
|
+
const filterGraph = createFilterGraph(keepRanges, media.hasAudio);
|
|
27
|
+
writeFileSync(filterPath, filterGraph, { flag: 'wx' });
|
|
28
|
+
let outputCreated = false;
|
|
29
|
+
try {
|
|
30
|
+
const args = ['-nostdin', '-hide_banner', '-v', 'error', '-n', '-i', input,
|
|
31
|
+
'-/filter_complex', filterPath, '-map', '[v]'];
|
|
32
|
+
if (media.hasAudio) args.push('-map', '[a]');
|
|
33
|
+
else args.push('-an');
|
|
34
|
+
args.push('-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-pix_fmt', 'yuv420p');
|
|
35
|
+
if (media.hasAudio) args.push('-c:a', 'aac', '-b:a', '192k');
|
|
36
|
+
args.push('-movflags', '+faststart', temporaryOutput);
|
|
37
|
+
let result = spawnSync('ffmpeg', args, { encoding: 'utf8', windowsHide: true });
|
|
38
|
+
if (result.status !== 0 && /unrecognized option ['"]?\/?filter_complex|option not found/i.test(result.stderr)) {
|
|
39
|
+
const optionIndex = args.indexOf('-/filter_complex');
|
|
40
|
+
args.splice(optionIndex, 2, '-filter_complex', filterGraph);
|
|
41
|
+
result = spawnSync('ffmpeg', args, { encoding: 'utf8', windowsHide: true });
|
|
42
|
+
}
|
|
43
|
+
if (result.error || result.status !== 0) {
|
|
44
|
+
throw new Error(`ffmpeg failed: ${result.error?.message ?? result.stderr.trim()}`);
|
|
45
|
+
}
|
|
46
|
+
closeSync(openSync(output, 'wx'));
|
|
47
|
+
outputCreated = true;
|
|
48
|
+
copyFileSync(temporaryOutput, output);
|
|
49
|
+
const rendered = probeMedia(output);
|
|
50
|
+
const expectedDuration = keepRanges.reduce((sum, range) => sum + range.end - range.start, 0);
|
|
51
|
+
if (Math.abs(rendered.durationSeconds - expectedDuration) > Math.max(0.15, 1 / (media.frameRate || 30))) {
|
|
52
|
+
throw new Error(`Rendered duration ${rendered.durationSeconds.toFixed(3)}s does not match expected ${expectedDuration.toFixed(3)}s.`);
|
|
53
|
+
}
|
|
54
|
+
if (media.hasAudio !== rendered.hasAudio) throw new Error('Rendered audio stream does not match the source.');
|
|
55
|
+
return { input, output, keepRanges, durationSeconds: keepRanges.reduce((sum, range) => sum + range.end - range.start, 0) };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (outputCreated && existsSync(output)) unlinkSync(output);
|
|
58
|
+
throw error;
|
|
59
|
+
} finally {
|
|
60
|
+
rmSync(filterPath, { force: true });
|
|
61
|
+
rmSync(temporaryOutput, { force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getKeepRanges(durationSeconds, cuts) {
|
|
66
|
+
const ranges = [];
|
|
67
|
+
let cursor = 0;
|
|
68
|
+
for (const cut of cuts) {
|
|
69
|
+
if (cut.sourceStart > cursor) ranges.push({ start: cursor, end: cut.sourceStart });
|
|
70
|
+
cursor = cut.sourceEnd;
|
|
71
|
+
}
|
|
72
|
+
if (cursor < durationSeconds) ranges.push({ start: cursor, end: durationSeconds });
|
|
73
|
+
return ranges;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createFilterGraph(ranges, hasAudio) {
|
|
77
|
+
const lines = [];
|
|
78
|
+
for (const [index, range] of ranges.entries()) {
|
|
79
|
+
lines.push(`[0:v]trim=start=${range.start}:end=${range.end},setpts=PTS-STARTPTS[v${index}]`);
|
|
80
|
+
if (hasAudio) lines.push(`[0:a]atrim=start=${range.start}:end=${range.end},asetpts=PTS-STARTPTS[a${index}]`);
|
|
81
|
+
}
|
|
82
|
+
const videoInputs = ranges.map((_, index) => `[v${index}]`).join('');
|
|
83
|
+
if (hasAudio) {
|
|
84
|
+
const inputs = ranges.map((_, index) => `[v${index}][a${index}]`).join('');
|
|
85
|
+
lines.push(`${inputs}concat=n=${ranges.length}:v=1:a=1[v][a]`);
|
|
86
|
+
} else {
|
|
87
|
+
lines.push(`${videoInputs}concat=n=${ranges.length}:v=1:a=0[v]`);
|
|
88
|
+
}
|
|
89
|
+
return `${lines.join(';\n')}\n`;
|
|
90
|
+
}
|