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
|
@@ -1,75 +1,75 @@
|
|
|
1
|
-
import { probeMedia } from './probe.mjs';
|
|
2
|
-
|
|
3
|
-
/** Run a pluggable media analyzer and normalize its time-coded findings. */
|
|
4
|
-
export async function analyzeMediaWithProvider(mediaPath, provider, options = {}) {
|
|
5
|
-
const media = probeMedia(mediaPath, { requireVideo: false });
|
|
6
|
-
if (!provider || typeof provider !== 'object'
|
|
7
|
-
|| !/^[a-z][a-z0-9-]*$/.test(provider.id ?? '')
|
|
8
|
-
|| typeof provider.analyze !== 'function') {
|
|
9
|
-
throw new TypeError('Provider must define a kebab-case id and async analyze function.');
|
|
10
|
-
}
|
|
11
|
-
const findings = await provider.analyze({
|
|
12
|
-
mediaPath: media.path,
|
|
13
|
-
durationSeconds: media.durationSeconds,
|
|
14
|
-
media: structuredClone(media),
|
|
15
|
-
options: structuredClone(options.providerOptions ?? {}),
|
|
16
|
-
});
|
|
17
|
-
return {
|
|
18
|
-
providerId: provider.id,
|
|
19
|
-
analysis: normalizeMediaFindings(findings, media.durationSeconds),
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function normalizeMediaFindings(input, durationSeconds) {
|
|
24
|
-
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
25
|
-
throw new TypeError('Media analysis durationSeconds must be positive.');
|
|
26
|
-
}
|
|
27
|
-
if (!input || typeof input !== 'object' || Array.isArray(input) || !Array.isArray(input.findings)) {
|
|
28
|
-
throw new TypeError('Media analysis result must contain a findings array.');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const findings = input.findings.map((finding, index) => {
|
|
32
|
-
const label = `Media analysis finding ${index + 1}`;
|
|
33
|
-
if (!finding || typeof finding !== 'object' || Array.isArray(finding)
|
|
34
|
-
|| typeof finding.type !== 'string' || !/^[a-z][a-z0-9-]*$/.test(finding.type)) {
|
|
35
|
-
throw new TypeError(`${label} must define a kebab-case type.`);
|
|
36
|
-
}
|
|
37
|
-
const { startSeconds, endSeconds, confidence, description } = finding;
|
|
38
|
-
if (!Number.isFinite(startSeconds) || startSeconds < 0 || startSeconds >= durationSeconds) {
|
|
39
|
-
throw new TypeError(`${label} startSeconds must be inside the media duration.`);
|
|
40
|
-
}
|
|
41
|
-
if (endSeconds !== undefined
|
|
42
|
-
&& (!Number.isFinite(endSeconds) || endSeconds <= startSeconds || endSeconds > durationSeconds)) {
|
|
43
|
-
throw new TypeError(`${label} endSeconds must be after startSeconds and within the media duration.`);
|
|
44
|
-
}
|
|
45
|
-
if (confidence !== undefined && (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)) {
|
|
46
|
-
throw new TypeError(`${label} confidence must be between 0 and 1.`);
|
|
47
|
-
}
|
|
48
|
-
if (description !== undefined && (typeof description !== 'string' || description.length > 1000)) {
|
|
49
|
-
throw new TypeError(`${label} description must be a string up to 1000 characters.`);
|
|
50
|
-
}
|
|
51
|
-
return {
|
|
52
|
-
id: `finding-${String(index + 1).padStart(3, '0')}`,
|
|
53
|
-
type: finding.type,
|
|
54
|
-
startSeconds,
|
|
55
|
-
...(endSeconds !== undefined ? { endSeconds } : {}),
|
|
56
|
-
...(confidence !== undefined ? { confidence } : {}),
|
|
57
|
-
...(description !== undefined ? { description } : {}),
|
|
58
|
-
...(finding.suggestion !== undefined ? { suggestion: normalizeSuggestedEdit(finding.suggestion, label, durationSeconds) } : {}),
|
|
59
|
-
};
|
|
60
|
-
}).sort((left, right) => left.startSeconds - right.startSeconds || left.id.localeCompare(right.id));
|
|
61
|
-
|
|
62
|
-
return { schemaVersion: 1, durationSeconds, findings };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function normalizeSuggestedEdit(suggestion, label, durationSeconds) {
|
|
66
|
-
if (!suggestion || typeof suggestion !== 'object' || Array.isArray(suggestion)
|
|
67
|
-
|| Object.keys(suggestion).some((field) => !['type', 'sourceStart', 'sourceEnd', 'reason'].includes(field))
|
|
68
|
-
|| suggestion.type !== 'remove-range'
|
|
69
|
-
|| !Number.isFinite(suggestion.sourceStart) || suggestion.sourceStart < 0 || suggestion.sourceStart >= durationSeconds
|
|
70
|
-
|| !Number.isFinite(suggestion.sourceEnd) || suggestion.sourceEnd <= suggestion.sourceStart || suggestion.sourceEnd > durationSeconds
|
|
71
|
-
|| typeof suggestion.reason !== 'string' || !suggestion.reason.trim() || suggestion.reason.length > 500) {
|
|
72
|
-
throw new TypeError(`${label} suggestion must define a valid remove-range with source bounds and a reason.`);
|
|
73
|
-
}
|
|
74
|
-
return structuredClone(suggestion);
|
|
75
|
-
}
|
|
1
|
+
import { probeMedia } from './probe.mjs';
|
|
2
|
+
|
|
3
|
+
/** Run a pluggable media analyzer and normalize its time-coded findings. */
|
|
4
|
+
export async function analyzeMediaWithProvider(mediaPath, provider, options = {}) {
|
|
5
|
+
const media = probeMedia(mediaPath, { requireVideo: false });
|
|
6
|
+
if (!provider || typeof provider !== 'object'
|
|
7
|
+
|| !/^[a-z][a-z0-9-]*$/.test(provider.id ?? '')
|
|
8
|
+
|| typeof provider.analyze !== 'function') {
|
|
9
|
+
throw new TypeError('Provider must define a kebab-case id and async analyze function.');
|
|
10
|
+
}
|
|
11
|
+
const findings = await provider.analyze({
|
|
12
|
+
mediaPath: media.path,
|
|
13
|
+
durationSeconds: media.durationSeconds,
|
|
14
|
+
media: structuredClone(media),
|
|
15
|
+
options: structuredClone(options.providerOptions ?? {}),
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
providerId: provider.id,
|
|
19
|
+
analysis: normalizeMediaFindings(findings, media.durationSeconds),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function normalizeMediaFindings(input, durationSeconds) {
|
|
24
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
25
|
+
throw new TypeError('Media analysis durationSeconds must be positive.');
|
|
26
|
+
}
|
|
27
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || !Array.isArray(input.findings)) {
|
|
28
|
+
throw new TypeError('Media analysis result must contain a findings array.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const findings = input.findings.map((finding, index) => {
|
|
32
|
+
const label = `Media analysis finding ${index + 1}`;
|
|
33
|
+
if (!finding || typeof finding !== 'object' || Array.isArray(finding)
|
|
34
|
+
|| typeof finding.type !== 'string' || !/^[a-z][a-z0-9-]*$/.test(finding.type)) {
|
|
35
|
+
throw new TypeError(`${label} must define a kebab-case type.`);
|
|
36
|
+
}
|
|
37
|
+
const { startSeconds, endSeconds, confidence, description } = finding;
|
|
38
|
+
if (!Number.isFinite(startSeconds) || startSeconds < 0 || startSeconds >= durationSeconds) {
|
|
39
|
+
throw new TypeError(`${label} startSeconds must be inside the media duration.`);
|
|
40
|
+
}
|
|
41
|
+
if (endSeconds !== undefined
|
|
42
|
+
&& (!Number.isFinite(endSeconds) || endSeconds <= startSeconds || endSeconds > durationSeconds)) {
|
|
43
|
+
throw new TypeError(`${label} endSeconds must be after startSeconds and within the media duration.`);
|
|
44
|
+
}
|
|
45
|
+
if (confidence !== undefined && (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)) {
|
|
46
|
+
throw new TypeError(`${label} confidence must be between 0 and 1.`);
|
|
47
|
+
}
|
|
48
|
+
if (description !== undefined && (typeof description !== 'string' || description.length > 1000)) {
|
|
49
|
+
throw new TypeError(`${label} description must be a string up to 1000 characters.`);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
id: `finding-${String(index + 1).padStart(3, '0')}`,
|
|
53
|
+
type: finding.type,
|
|
54
|
+
startSeconds,
|
|
55
|
+
...(endSeconds !== undefined ? { endSeconds } : {}),
|
|
56
|
+
...(confidence !== undefined ? { confidence } : {}),
|
|
57
|
+
...(description !== undefined ? { description } : {}),
|
|
58
|
+
...(finding.suggestion !== undefined ? { suggestion: normalizeSuggestedEdit(finding.suggestion, label, durationSeconds) } : {}),
|
|
59
|
+
};
|
|
60
|
+
}).sort((left, right) => left.startSeconds - right.startSeconds || left.id.localeCompare(right.id));
|
|
61
|
+
|
|
62
|
+
return { schemaVersion: 1, durationSeconds, findings };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeSuggestedEdit(suggestion, label, durationSeconds) {
|
|
66
|
+
if (!suggestion || typeof suggestion !== 'object' || Array.isArray(suggestion)
|
|
67
|
+
|| Object.keys(suggestion).some((field) => !['type', 'sourceStart', 'sourceEnd', 'reason'].includes(field))
|
|
68
|
+
|| suggestion.type !== 'remove-range'
|
|
69
|
+
|| !Number.isFinite(suggestion.sourceStart) || suggestion.sourceStart < 0 || suggestion.sourceStart >= durationSeconds
|
|
70
|
+
|| !Number.isFinite(suggestion.sourceEnd) || suggestion.sourceEnd <= suggestion.sourceStart || suggestion.sourceEnd > durationSeconds
|
|
71
|
+
|| typeof suggestion.reason !== 'string' || !suggestion.reason.trim() || suggestion.reason.length > 500) {
|
|
72
|
+
throw new TypeError(`${label} suggestion must define a valid remove-range with source bounds and a reason.`);
|
|
73
|
+
}
|
|
74
|
+
return structuredClone(suggestion);
|
|
75
|
+
}
|
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
import { probeMediaAsync } from './probe.mjs';
|
|
2
|
-
import { listFfmpegDecoders, listFfmpegDemuxers } from './ffmpeg-capabilities.mjs';
|
|
3
|
-
|
|
4
|
-
/** Probe a media file and report whether the local FFmpeg build has its demuxer and stream decoders. */
|
|
5
|
-
export async function inspectMediaCompatibility(inputPath, options = {}) {
|
|
6
|
-
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Media compatibility options must be an object.');
|
|
7
|
-
for (const field of Object.keys(options)) if (!['ffmpeg', 'signal'].includes(field)) throw new TypeError(`Unsupported media compatibility option: ${field}.`);
|
|
8
|
-
const ffmpeg = options.ffmpeg ?? 'ffmpeg';
|
|
9
|
-
if (typeof ffmpeg !== 'string' || !ffmpeg.trim()) throw new TypeError('ffmpeg must be a non-empty command path.');
|
|
10
|
-
|
|
11
|
-
const media = await probeMediaAsync(inputPath, { requireVideo: false, signal: options.signal });
|
|
12
|
-
const demuxers = listFfmpegDemuxers(ffmpeg);
|
|
13
|
-
const decoders = listFfmpegDecoders(ffmpeg);
|
|
14
|
-
const sourceFormats = typeof media.formatName === 'string'
|
|
15
|
-
? media.formatName.split(',').map((name) => name.trim()).filter(Boolean)
|
|
16
|
-
: [];
|
|
17
|
-
const containerAvailable = sourceFormats.some((format) => demuxers.has(format));
|
|
18
|
-
const video = media.videoStreams.map((stream) => ({
|
|
19
|
-
index: stream.index,
|
|
20
|
-
streamIndex: stream.streamIndex,
|
|
21
|
-
codec: stream.codec,
|
|
22
|
-
available: typeof stream.codec === 'string' && decoders.has(stream.codec),
|
|
23
|
-
}));
|
|
24
|
-
const audio = media.audioStreams.map((stream) => ({
|
|
25
|
-
index: stream.index,
|
|
26
|
-
streamIndex: stream.streamIndex,
|
|
27
|
-
codec: stream.codec,
|
|
28
|
-
available: typeof stream.codec === 'string' && decoders.has(stream.codec),
|
|
29
|
-
}));
|
|
30
|
-
const unsupported = [];
|
|
31
|
-
if (!containerAvailable) unsupported.push({ kind: 'container', formats: sourceFormats });
|
|
32
|
-
for (const stream of [...video, ...audio]) {
|
|
33
|
-
if (!stream.available) unsupported.push({ kind: 'decoder', streamIndex: stream.streamIndex, codec: stream.codec });
|
|
34
|
-
}
|
|
35
|
-
return {
|
|
36
|
-
schemaVersion: 1,
|
|
37
|
-
path: media.path,
|
|
38
|
-
durationSeconds: media.durationSeconds,
|
|
39
|
-
sourceFormats,
|
|
40
|
-
containerAvailable,
|
|
41
|
-
streams: { video, audio },
|
|
42
|
-
compatible: unsupported.length === 0 && video.length + audio.length > 0,
|
|
43
|
-
unsupported,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
1
|
+
import { probeMediaAsync } from './probe.mjs';
|
|
2
|
+
import { listFfmpegDecoders, listFfmpegDemuxers } from './ffmpeg-capabilities.mjs';
|
|
3
|
+
|
|
4
|
+
/** Probe a media file and report whether the local FFmpeg build has its demuxer and stream decoders. */
|
|
5
|
+
export async function inspectMediaCompatibility(inputPath, options = {}) {
|
|
6
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Media compatibility options must be an object.');
|
|
7
|
+
for (const field of Object.keys(options)) if (!['ffmpeg', 'signal'].includes(field)) throw new TypeError(`Unsupported media compatibility option: ${field}.`);
|
|
8
|
+
const ffmpeg = options.ffmpeg ?? 'ffmpeg';
|
|
9
|
+
if (typeof ffmpeg !== 'string' || !ffmpeg.trim()) throw new TypeError('ffmpeg must be a non-empty command path.');
|
|
10
|
+
|
|
11
|
+
const media = await probeMediaAsync(inputPath, { requireVideo: false, signal: options.signal });
|
|
12
|
+
const demuxers = listFfmpegDemuxers(ffmpeg);
|
|
13
|
+
const decoders = listFfmpegDecoders(ffmpeg);
|
|
14
|
+
const sourceFormats = typeof media.formatName === 'string'
|
|
15
|
+
? media.formatName.split(',').map((name) => name.trim()).filter(Boolean)
|
|
16
|
+
: [];
|
|
17
|
+
const containerAvailable = sourceFormats.some((format) => demuxers.has(format));
|
|
18
|
+
const video = media.videoStreams.map((stream) => ({
|
|
19
|
+
index: stream.index,
|
|
20
|
+
streamIndex: stream.streamIndex,
|
|
21
|
+
codec: stream.codec,
|
|
22
|
+
available: typeof stream.codec === 'string' && decoders.has(stream.codec),
|
|
23
|
+
}));
|
|
24
|
+
const audio = media.audioStreams.map((stream) => ({
|
|
25
|
+
index: stream.index,
|
|
26
|
+
streamIndex: stream.streamIndex,
|
|
27
|
+
codec: stream.codec,
|
|
28
|
+
available: typeof stream.codec === 'string' && decoders.has(stream.codec),
|
|
29
|
+
}));
|
|
30
|
+
const unsupported = [];
|
|
31
|
+
if (!containerAvailable) unsupported.push({ kind: 'container', formats: sourceFormats });
|
|
32
|
+
for (const stream of [...video, ...audio]) {
|
|
33
|
+
if (!stream.available) unsupported.push({ kind: 'decoder', streamIndex: stream.streamIndex, codec: stream.codec });
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
path: media.path,
|
|
38
|
+
durationSeconds: media.durationSeconds,
|
|
39
|
+
sourceFormats,
|
|
40
|
+
containerAvailable,
|
|
41
|
+
streams: { video, audio },
|
|
42
|
+
compatible: unsupported.length === 0 && video.length + audio.length > 0,
|
|
43
|
+
unsupported,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -1,129 +1,129 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
export function parseFfmpegFilters(output) {
|
|
4
|
-
if (typeof output !== 'string') throw new TypeError('FFmpeg filter output must be text.');
|
|
5
|
-
return new Set(output.split(/\r?\n/).flatMap((line) => {
|
|
6
|
-
const [flags, name, inputOutput] = line.trim().split(/\s+/, 3);
|
|
7
|
-
return flags && /^[TSC.]{2,3}$/.test(flags) && name && inputOutput?.includes('->') ? [name] : [];
|
|
8
|
-
}));
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function hasFfmpegFilter(filterName, ffmpeg = 'ffmpeg') {
|
|
12
|
-
if (typeof filterName !== 'string' || !/^[a-z0-9_]+$/.test(filterName)) throw new TypeError('FFmpeg filter name is invalid.');
|
|
13
|
-
return listFfmpegFilters(ffmpeg).has(filterName);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function listFfmpegFilters(ffmpeg = 'ffmpeg') {
|
|
17
|
-
const result = spawnSync(ffmpeg, ['-hide_banner', '-filters'], {
|
|
18
|
-
encoding: 'utf8',
|
|
19
|
-
windowsHide: true,
|
|
20
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
21
|
-
});
|
|
22
|
-
if (result.error || result.status !== 0) {
|
|
23
|
-
throw new Error(`Could not inspect FFmpeg filters: ${result.error?.message ?? result.stderr.trim()}`);
|
|
24
|
-
}
|
|
25
|
-
return parseFfmpegFilters(result.stdout);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function parseFfmpegEncoders(output) {
|
|
29
|
-
if (typeof output !== 'string') throw new TypeError('FFmpeg encoder output must be text.');
|
|
30
|
-
return new Set(output.split(/\r?\n/).flatMap((line) => {
|
|
31
|
-
const match = line.trim().match(/^[A-Z.]{6}\s+([a-z0-9_-]+)\s+/i);
|
|
32
|
-
return match ? [match[1]] : [];
|
|
33
|
-
}));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function parseFfmpegDecoders(output) {
|
|
37
|
-
if (typeof output !== 'string') throw new TypeError('FFmpeg decoder output must be text.');
|
|
38
|
-
return new Set(output.split(/\r?\n/).flatMap((line) => {
|
|
39
|
-
const match = line.trim().match(/^[A-Z.]{6}\s+([a-z0-9_-]+)\s+/i);
|
|
40
|
-
return match ? [match[1]] : [];
|
|
41
|
-
}));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function parseFfmpegDemuxers(output) {
|
|
45
|
-
if (typeof output !== 'string') throw new TypeError('FFmpeg demuxer output must be text.');
|
|
46
|
-
return new Set(output.split(/\r?\n/).flatMap((line) => {
|
|
47
|
-
const match = line.trim().match(/^D\s+([a-z0-9_,]+)\s+/i);
|
|
48
|
-
return match ? match[1].split(',').map((name) => name.trim()).filter(Boolean) : [];
|
|
49
|
-
}));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function assertFfmpegEncoder(encoderName, availableEncoders) {
|
|
53
|
-
if (typeof encoderName !== 'string' || !/^[a-z0-9_-]+$/i.test(encoderName)) throw new TypeError('FFmpeg encoder name is invalid.');
|
|
54
|
-
if (!(availableEncoders instanceof Set)) throw new TypeError('Available FFmpeg encoders must be a Set.');
|
|
55
|
-
if (!availableEncoders.has(encoderName)) {
|
|
56
|
-
throw new Error(`FFmpeg encoder "${encoderName}" is unavailable in this build. Install an FFmpeg build with this encoder or select another codec.`);
|
|
57
|
-
}
|
|
58
|
-
return encoderName;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function assertFfmpegDecoder(decoderName, availableDecoders) {
|
|
62
|
-
if (typeof decoderName !== 'string' || !/^[a-z0-9_-]+$/i.test(decoderName)) throw new TypeError('FFmpeg decoder name is invalid.');
|
|
63
|
-
if (!(availableDecoders instanceof Set)) throw new TypeError('Available FFmpeg decoders must be a Set.');
|
|
64
|
-
if (!availableDecoders.has(decoderName)) {
|
|
65
|
-
throw new Error(`FFmpeg decoder "${decoderName}" is unavailable in this build. Install an FFmpeg build with this decoder or choose another input format.`);
|
|
66
|
-
}
|
|
67
|
-
return decoderName;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function assertFfmpegDemuxer(demuxerName, availableDemuxers) {
|
|
71
|
-
if (typeof demuxerName !== 'string' || !/^[a-z0-9_]+$/i.test(demuxerName)) throw new TypeError('FFmpeg demuxer name is invalid.');
|
|
72
|
-
if (!(availableDemuxers instanceof Set)) throw new TypeError('Available FFmpeg demuxers must be a Set.');
|
|
73
|
-
if (!availableDemuxers.has(demuxerName)) {
|
|
74
|
-
throw new Error(`FFmpeg demuxer "${demuxerName}" is unavailable in this build. Install an FFmpeg build with this demuxer or choose another container.`);
|
|
75
|
-
}
|
|
76
|
-
return demuxerName;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function hasFfmpegEncoder(encoderName, ffmpeg = 'ffmpeg') {
|
|
80
|
-
if (typeof encoderName !== 'string' || !/^[a-z0-9_-]+$/i.test(encoderName)) throw new TypeError('FFmpeg encoder name is invalid.');
|
|
81
|
-
assertFfmpegEncoder(encoderName, listFfmpegEncoders(ffmpeg));
|
|
82
|
-
return true;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function hasFfmpegDecoder(decoderName, ffmpeg = 'ffmpeg') {
|
|
86
|
-
if (typeof decoderName !== 'string' || !/^[a-z0-9_-]+$/i.test(decoderName)) throw new TypeError('FFmpeg decoder name is invalid.');
|
|
87
|
-
return listFfmpegDecoders(ffmpeg).has(decoderName);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function hasFfmpegDemuxer(demuxerName, ffmpeg = 'ffmpeg') {
|
|
91
|
-
if (typeof demuxerName !== 'string' || !/^[a-z0-9_]+$/i.test(demuxerName)) throw new TypeError('FFmpeg demuxer name is invalid.');
|
|
92
|
-
return listFfmpegDemuxers(ffmpeg).has(demuxerName);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function listFfmpegEncoders(ffmpeg = 'ffmpeg') {
|
|
96
|
-
const result = spawnSync(ffmpeg, ['-hide_banner', '-encoders'], {
|
|
97
|
-
encoding: 'utf8',
|
|
98
|
-
windowsHide: true,
|
|
99
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
100
|
-
});
|
|
101
|
-
if (result.error || result.status !== 0) {
|
|
102
|
-
throw new Error(`Could not inspect FFmpeg encoders: ${result.error?.message ?? result.stderr.trim()}`);
|
|
103
|
-
}
|
|
104
|
-
return parseFfmpegEncoders(result.stdout);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function listFfmpegDecoders(ffmpeg = 'ffmpeg') {
|
|
108
|
-
const result = spawnSync(ffmpeg, ['-hide_banner', '-decoders'], {
|
|
109
|
-
encoding: 'utf8',
|
|
110
|
-
windowsHide: true,
|
|
111
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
112
|
-
});
|
|
113
|
-
if (result.error || result.status !== 0) {
|
|
114
|
-
throw new Error(`Could not inspect FFmpeg decoders: ${result.error?.message ?? result.stderr.trim()}`);
|
|
115
|
-
}
|
|
116
|
-
return parseFfmpegDecoders(result.stdout);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function listFfmpegDemuxers(ffmpeg = 'ffmpeg') {
|
|
120
|
-
const result = spawnSync(ffmpeg, ['-hide_banner', '-demuxers'], {
|
|
121
|
-
encoding: 'utf8',
|
|
122
|
-
windowsHide: true,
|
|
123
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
124
|
-
});
|
|
125
|
-
if (result.error || result.status !== 0) {
|
|
126
|
-
throw new Error(`Could not inspect FFmpeg demuxers: ${result.error?.message ?? result.stderr.trim()}`);
|
|
127
|
-
}
|
|
128
|
-
return parseFfmpegDemuxers(result.stdout);
|
|
129
|
-
}
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function parseFfmpegFilters(output) {
|
|
4
|
+
if (typeof output !== 'string') throw new TypeError('FFmpeg filter output must be text.');
|
|
5
|
+
return new Set(output.split(/\r?\n/).flatMap((line) => {
|
|
6
|
+
const [flags, name, inputOutput] = line.trim().split(/\s+/, 3);
|
|
7
|
+
return flags && /^[TSC.]{2,3}$/.test(flags) && name && inputOutput?.includes('->') ? [name] : [];
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function hasFfmpegFilter(filterName, ffmpeg = 'ffmpeg') {
|
|
12
|
+
if (typeof filterName !== 'string' || !/^[a-z0-9_]+$/.test(filterName)) throw new TypeError('FFmpeg filter name is invalid.');
|
|
13
|
+
return listFfmpegFilters(ffmpeg).has(filterName);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function listFfmpegFilters(ffmpeg = 'ffmpeg') {
|
|
17
|
+
const result = spawnSync(ffmpeg, ['-hide_banner', '-filters'], {
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
21
|
+
});
|
|
22
|
+
if (result.error || result.status !== 0) {
|
|
23
|
+
throw new Error(`Could not inspect FFmpeg filters: ${result.error?.message ?? result.stderr.trim()}`);
|
|
24
|
+
}
|
|
25
|
+
return parseFfmpegFilters(result.stdout);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseFfmpegEncoders(output) {
|
|
29
|
+
if (typeof output !== 'string') throw new TypeError('FFmpeg encoder output must be text.');
|
|
30
|
+
return new Set(output.split(/\r?\n/).flatMap((line) => {
|
|
31
|
+
const match = line.trim().match(/^[A-Z.]{6}\s+([a-z0-9_-]+)\s+/i);
|
|
32
|
+
return match ? [match[1]] : [];
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseFfmpegDecoders(output) {
|
|
37
|
+
if (typeof output !== 'string') throw new TypeError('FFmpeg decoder output must be text.');
|
|
38
|
+
return new Set(output.split(/\r?\n/).flatMap((line) => {
|
|
39
|
+
const match = line.trim().match(/^[A-Z.]{6}\s+([a-z0-9_-]+)\s+/i);
|
|
40
|
+
return match ? [match[1]] : [];
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseFfmpegDemuxers(output) {
|
|
45
|
+
if (typeof output !== 'string') throw new TypeError('FFmpeg demuxer output must be text.');
|
|
46
|
+
return new Set(output.split(/\r?\n/).flatMap((line) => {
|
|
47
|
+
const match = line.trim().match(/^D\s+([a-z0-9_,]+)\s+/i);
|
|
48
|
+
return match ? match[1].split(',').map((name) => name.trim()).filter(Boolean) : [];
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function assertFfmpegEncoder(encoderName, availableEncoders) {
|
|
53
|
+
if (typeof encoderName !== 'string' || !/^[a-z0-9_-]+$/i.test(encoderName)) throw new TypeError('FFmpeg encoder name is invalid.');
|
|
54
|
+
if (!(availableEncoders instanceof Set)) throw new TypeError('Available FFmpeg encoders must be a Set.');
|
|
55
|
+
if (!availableEncoders.has(encoderName)) {
|
|
56
|
+
throw new Error(`FFmpeg encoder "${encoderName}" is unavailable in this build. Install an FFmpeg build with this encoder or select another codec.`);
|
|
57
|
+
}
|
|
58
|
+
return encoderName;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function assertFfmpegDecoder(decoderName, availableDecoders) {
|
|
62
|
+
if (typeof decoderName !== 'string' || !/^[a-z0-9_-]+$/i.test(decoderName)) throw new TypeError('FFmpeg decoder name is invalid.');
|
|
63
|
+
if (!(availableDecoders instanceof Set)) throw new TypeError('Available FFmpeg decoders must be a Set.');
|
|
64
|
+
if (!availableDecoders.has(decoderName)) {
|
|
65
|
+
throw new Error(`FFmpeg decoder "${decoderName}" is unavailable in this build. Install an FFmpeg build with this decoder or choose another input format.`);
|
|
66
|
+
}
|
|
67
|
+
return decoderName;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function assertFfmpegDemuxer(demuxerName, availableDemuxers) {
|
|
71
|
+
if (typeof demuxerName !== 'string' || !/^[a-z0-9_]+$/i.test(demuxerName)) throw new TypeError('FFmpeg demuxer name is invalid.');
|
|
72
|
+
if (!(availableDemuxers instanceof Set)) throw new TypeError('Available FFmpeg demuxers must be a Set.');
|
|
73
|
+
if (!availableDemuxers.has(demuxerName)) {
|
|
74
|
+
throw new Error(`FFmpeg demuxer "${demuxerName}" is unavailable in this build. Install an FFmpeg build with this demuxer or choose another container.`);
|
|
75
|
+
}
|
|
76
|
+
return demuxerName;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function hasFfmpegEncoder(encoderName, ffmpeg = 'ffmpeg') {
|
|
80
|
+
if (typeof encoderName !== 'string' || !/^[a-z0-9_-]+$/i.test(encoderName)) throw new TypeError('FFmpeg encoder name is invalid.');
|
|
81
|
+
assertFfmpegEncoder(encoderName, listFfmpegEncoders(ffmpeg));
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function hasFfmpegDecoder(decoderName, ffmpeg = 'ffmpeg') {
|
|
86
|
+
if (typeof decoderName !== 'string' || !/^[a-z0-9_-]+$/i.test(decoderName)) throw new TypeError('FFmpeg decoder name is invalid.');
|
|
87
|
+
return listFfmpegDecoders(ffmpeg).has(decoderName);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function hasFfmpegDemuxer(demuxerName, ffmpeg = 'ffmpeg') {
|
|
91
|
+
if (typeof demuxerName !== 'string' || !/^[a-z0-9_]+$/i.test(demuxerName)) throw new TypeError('FFmpeg demuxer name is invalid.');
|
|
92
|
+
return listFfmpegDemuxers(ffmpeg).has(demuxerName);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function listFfmpegEncoders(ffmpeg = 'ffmpeg') {
|
|
96
|
+
const result = spawnSync(ffmpeg, ['-hide_banner', '-encoders'], {
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
windowsHide: true,
|
|
99
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
100
|
+
});
|
|
101
|
+
if (result.error || result.status !== 0) {
|
|
102
|
+
throw new Error(`Could not inspect FFmpeg encoders: ${result.error?.message ?? result.stderr.trim()}`);
|
|
103
|
+
}
|
|
104
|
+
return parseFfmpegEncoders(result.stdout);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function listFfmpegDecoders(ffmpeg = 'ffmpeg') {
|
|
108
|
+
const result = spawnSync(ffmpeg, ['-hide_banner', '-decoders'], {
|
|
109
|
+
encoding: 'utf8',
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
112
|
+
});
|
|
113
|
+
if (result.error || result.status !== 0) {
|
|
114
|
+
throw new Error(`Could not inspect FFmpeg decoders: ${result.error?.message ?? result.stderr.trim()}`);
|
|
115
|
+
}
|
|
116
|
+
return parseFfmpegDecoders(result.stdout);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function listFfmpegDemuxers(ffmpeg = 'ffmpeg') {
|
|
120
|
+
const result = spawnSync(ffmpeg, ['-hide_banner', '-demuxers'], {
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
windowsHide: true,
|
|
123
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
124
|
+
});
|
|
125
|
+
if (result.error || result.status !== 0) {
|
|
126
|
+
throw new Error(`Could not inspect FFmpeg demuxers: ${result.error?.message ?? result.stderr.trim()}`);
|
|
127
|
+
}
|
|
128
|
+
return parseFfmpegDemuxers(result.stdout);
|
|
129
|
+
}
|
package/src/media/frame-rate.mjs
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
const COMMON_RATES = [
|
|
2
|
-
[23.976, 24000, 1001],
|
|
3
|
-
[29.97, 30000, 1001],
|
|
4
|
-
[59.94, 60000, 1001],
|
|
5
|
-
[119.88, 120000, 1001],
|
|
6
|
-
];
|
|
7
|
-
|
|
8
|
-
export function normalizeFrameRate(value) {
|
|
9
|
-
if (typeof value === 'number') {
|
|
10
|
-
if (!Number.isFinite(value)) throw new TypeError('Frame rate must be finite.');
|
|
11
|
-
const common = COMMON_RATES.find(([rate]) => Math.abs(rate - value) < 0.0005);
|
|
12
|
-
if (common) return `${common[1]}/${common[2]}`;
|
|
13
|
-
return normalizeFrameRate(String(value));
|
|
14
|
-
}
|
|
15
|
-
if (typeof value !== 'string') throw new TypeError('Frame rate must be an integer, decimal, or rational string.');
|
|
16
|
-
const input = value.trim();
|
|
17
|
-
const rational = /^(\d+)\/(\d+)$/.exec(input);
|
|
18
|
-
if (rational) {
|
|
19
|
-
let numerator = Number(rational[1]);
|
|
20
|
-
let denominator = Number(rational[2]);
|
|
21
|
-
if (!Number.isSafeInteger(numerator) || !Number.isSafeInteger(denominator) || denominator <= 0) throw new TypeError('Frame rate rational must use positive safe integers.');
|
|
22
|
-
const divisor = gcd(numerator, denominator);
|
|
23
|
-
numerator /= divisor;
|
|
24
|
-
denominator /= divisor;
|
|
25
|
-
const rate = numerator / denominator;
|
|
26
|
-
validateRate(rate);
|
|
27
|
-
return denominator === 1 ? numerator : `${numerator}/${denominator}`;
|
|
28
|
-
}
|
|
29
|
-
if (!/^\d+(?:\.\d+)?$/.test(input)) throw new TypeError('Frame rate must be an integer, decimal, or numerator/denominator value.');
|
|
30
|
-
const decimal = Number(input);
|
|
31
|
-
if (!Number.isFinite(decimal)) throw new TypeError('Frame rate must be finite.');
|
|
32
|
-
const common = COMMON_RATES.find(([rate]) => Math.abs(rate - decimal) < 0.0005);
|
|
33
|
-
if (common) return `${common[1]}/${common[2]}`;
|
|
34
|
-
const [whole, fraction = ''] = input.split('.');
|
|
35
|
-
const denominator = 10 ** fraction.length;
|
|
36
|
-
const numerator = Number(whole) * denominator + Number(fraction || 0);
|
|
37
|
-
return normalizeFrameRate(`${numerator}/${denominator}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function frameRateNumber(value) {
|
|
41
|
-
if (typeof value === 'number') {
|
|
42
|
-
validateRate(value);
|
|
43
|
-
return value;
|
|
44
|
-
}
|
|
45
|
-
const normalized = normalizeFrameRate(value);
|
|
46
|
-
if (typeof normalized === 'number') return normalized;
|
|
47
|
-
const [numerator, denominator] = normalized.split('/').map(Number);
|
|
48
|
-
return numerator / denominator;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function validateRate(rate) {
|
|
52
|
-
if (!Number.isFinite(rate) || rate < 1 || rate > 240) throw new TypeError('Frame rate must be between 1 and 240 fps.');
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function gcd(a, b) {
|
|
56
|
-
while (b !== 0) [a, b] = [b, a % b];
|
|
57
|
-
return Math.abs(a);
|
|
58
|
-
}
|
|
1
|
+
const COMMON_RATES = [
|
|
2
|
+
[23.976, 24000, 1001],
|
|
3
|
+
[29.97, 30000, 1001],
|
|
4
|
+
[59.94, 60000, 1001],
|
|
5
|
+
[119.88, 120000, 1001],
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export function normalizeFrameRate(value) {
|
|
9
|
+
if (typeof value === 'number') {
|
|
10
|
+
if (!Number.isFinite(value)) throw new TypeError('Frame rate must be finite.');
|
|
11
|
+
const common = COMMON_RATES.find(([rate]) => Math.abs(rate - value) < 0.0005);
|
|
12
|
+
if (common) return `${common[1]}/${common[2]}`;
|
|
13
|
+
return normalizeFrameRate(String(value));
|
|
14
|
+
}
|
|
15
|
+
if (typeof value !== 'string') throw new TypeError('Frame rate must be an integer, decimal, or rational string.');
|
|
16
|
+
const input = value.trim();
|
|
17
|
+
const rational = /^(\d+)\/(\d+)$/.exec(input);
|
|
18
|
+
if (rational) {
|
|
19
|
+
let numerator = Number(rational[1]);
|
|
20
|
+
let denominator = Number(rational[2]);
|
|
21
|
+
if (!Number.isSafeInteger(numerator) || !Number.isSafeInteger(denominator) || denominator <= 0) throw new TypeError('Frame rate rational must use positive safe integers.');
|
|
22
|
+
const divisor = gcd(numerator, denominator);
|
|
23
|
+
numerator /= divisor;
|
|
24
|
+
denominator /= divisor;
|
|
25
|
+
const rate = numerator / denominator;
|
|
26
|
+
validateRate(rate);
|
|
27
|
+
return denominator === 1 ? numerator : `${numerator}/${denominator}`;
|
|
28
|
+
}
|
|
29
|
+
if (!/^\d+(?:\.\d+)?$/.test(input)) throw new TypeError('Frame rate must be an integer, decimal, or numerator/denominator value.');
|
|
30
|
+
const decimal = Number(input);
|
|
31
|
+
if (!Number.isFinite(decimal)) throw new TypeError('Frame rate must be finite.');
|
|
32
|
+
const common = COMMON_RATES.find(([rate]) => Math.abs(rate - decimal) < 0.0005);
|
|
33
|
+
if (common) return `${common[1]}/${common[2]}`;
|
|
34
|
+
const [whole, fraction = ''] = input.split('.');
|
|
35
|
+
const denominator = 10 ** fraction.length;
|
|
36
|
+
const numerator = Number(whole) * denominator + Number(fraction || 0);
|
|
37
|
+
return normalizeFrameRate(`${numerator}/${denominator}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function frameRateNumber(value) {
|
|
41
|
+
if (typeof value === 'number') {
|
|
42
|
+
validateRate(value);
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
const normalized = normalizeFrameRate(value);
|
|
46
|
+
if (typeof normalized === 'number') return normalized;
|
|
47
|
+
const [numerator, denominator] = normalized.split('/').map(Number);
|
|
48
|
+
return numerator / denominator;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateRate(rate) {
|
|
52
|
+
if (!Number.isFinite(rate) || rate < 1 || rate > 240) throw new TypeError('Frame rate must be between 1 and 240 fps.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function gcd(a, b) {
|
|
56
|
+
while (b !== 0) [a, b] = [b, a % b];
|
|
57
|
+
return Math.abs(a);
|
|
58
|
+
}
|