aivee 0.0.0-stage → 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 -0
- package/README.md +119 -2
- package/docs/architecture.md +401 -0
- package/docs/aspect-ratio-presets.md +72 -0
- package/docs/releasing.md +19 -0
- package/docs/usage.md +1193 -0
- package/package.json +50 -4
- package/requirements-otio.txt +1 -0
- package/src/capabilities.mjs +307 -0
- package/src/cli.mjs +646 -0
- package/src/doctor.mjs +133 -0
- package/src/edits/analysis-to-edl.mjs +43 -0
- package/src/edits/detect-silence.mjs +51 -0
- package/src/edits/find-mistake-candidates.mjs +42 -0
- package/src/edits/plan-shot-placements.mjs +105 -0
- package/src/edits/plan-silence-cuts.mjs +49 -0
- package/src/edits/retime-transcript.mjs +105 -0
- package/src/files.mjs +80 -0
- package/src/index.mjs +44 -0
- package/src/interchange/otio.mjs +204 -0
- package/src/interchange/otio_bridge.py +521 -0
- package/src/media/analysis-provider.mjs +75 -0
- package/src/media/compatibility.mjs +45 -0
- package/src/media/ffmpeg-capabilities.mjs +129 -0
- package/src/media/frame-rate.mjs +58 -0
- package/src/media/frame-time.mjs +22 -0
- package/src/media/preview-assets.mjs +350 -0
- package/src/media/probe.mjs +149 -0
- package/src/media/render-cuts.mjs +90 -0
- package/src/media/scene-detection.mjs +107 -0
- package/src/media/scene-review.mjs +205 -0
- package/src/project/assets.mjs +1194 -0
- package/src/project/create.mjs +100 -0
- package/src/project/presets.mjs +44 -0
- package/src/render/checkpoints.mjs +192 -0
- package/src/render/formats.mjs +163 -0
- package/src/render/hyperframes.mjs +590 -0
- package/src/render/process.mjs +111 -0
- package/src/render/progress.mjs +46 -0
- package/src/render/timeline-control.mjs +53 -0
- package/src/timeline/assemble.mjs +119 -0
- package/src/timeline/audio-effects.mjs +75 -0
- package/src/timeline/audio-mastering.mjs +236 -0
- package/src/timeline/audio-mix.mjs +105 -0
- package/src/timeline/audio-track-effects.mjs +139 -0
- package/src/timeline/audio-volume.mjs +63 -0
- package/src/timeline/background.mjs +8 -0
- package/src/timeline/blend-mode.mjs +34 -0
- package/src/timeline/caption-layouts.mjs +62 -0
- package/src/timeline/captions.mjs +80 -0
- package/src/timeline/edit.mjs +965 -0
- package/src/timeline/fingerprint.mjs +6 -0
- package/src/timeline/from-edl.mjs +31 -0
- package/src/timeline/history-store.mjs +281 -0
- package/src/timeline/history.mjs +69 -0
- package/src/timeline/inspect.mjs +117 -0
- package/src/timeline/keyframes.mjs +158 -0
- package/src/timeline/layers.mjs +18 -0
- package/src/timeline/mask.mjs +53 -0
- package/src/timeline/project.mjs +52 -0
- package/src/timeline/render.mjs +759 -0
- package/src/timeline/sidechain-render.mjs +209 -0
- package/src/timeline/source-fit.mjs +15 -0
- package/src/timeline/speech-bubble.mjs +26 -0
- package/src/timeline/speed-ramp.mjs +188 -0
- package/src/timeline/track-state.mjs +22 -0
- package/src/timeline/transition.mjs +34 -0
- package/src/timeline/validate.mjs +322 -0
- package/src/transcript/corrections.mjs +21 -0
- package/src/transcript/normalize.mjs +17 -0
- package/src/transcript/provider.mjs +42 -0
- package/src/transcript/subtitles.mjs +57 -0
- package/src/transcript/validate.mjs +39 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { probeMediaAsync } from './probe.mjs';
|
|
2
|
+
import { runProcess } from '../render/process.mjs';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_SCENE_THRESHOLD = 0.35;
|
|
5
|
+
const DEFAULT_MINIMUM_SPACING_SECONDS = 0.5;
|
|
6
|
+
const DEFAULT_MAXIMUM_FINDINGS = 2000;
|
|
7
|
+
|
|
8
|
+
/** Find visually distinct shot boundaries with FFmpeg's scene-change score. */
|
|
9
|
+
export async function detectSceneChangesAsync(inputPath, options = {}) {
|
|
10
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
11
|
+
throw new TypeError('Scene detection options must be an object.');
|
|
12
|
+
}
|
|
13
|
+
const allowedOptions = new Set(['threshold', 'minimumSpacingSeconds', 'maximumFindings', 'signal']);
|
|
14
|
+
for (const key of Object.keys(options)) {
|
|
15
|
+
if (!allowedOptions.has(key)) throw new TypeError(`Unsupported scene detection option: ${key}.`);
|
|
16
|
+
}
|
|
17
|
+
const media = await probeMediaAsync(inputPath, { signal: options.signal });
|
|
18
|
+
if (!media.videoStreams.length) throw new TypeError('Scene detection requires a video stream.');
|
|
19
|
+
|
|
20
|
+
const threshold = options.threshold ?? DEFAULT_SCENE_THRESHOLD;
|
|
21
|
+
const minimumSpacingSeconds = options.minimumSpacingSeconds ?? DEFAULT_MINIMUM_SPACING_SECONDS;
|
|
22
|
+
const maximumFindings = options.maximumFindings ?? DEFAULT_MAXIMUM_FINDINGS;
|
|
23
|
+
if (!Number.isFinite(threshold) || threshold < 0.05 || threshold > 1) {
|
|
24
|
+
throw new TypeError('Scene threshold must be between 0.05 and 1.');
|
|
25
|
+
}
|
|
26
|
+
if (!Number.isFinite(minimumSpacingSeconds) || minimumSpacingSeconds < 0 || minimumSpacingSeconds > 60) {
|
|
27
|
+
throw new TypeError('minimumSpacingSeconds must be between 0 and 60.');
|
|
28
|
+
}
|
|
29
|
+
if (!Number.isSafeInteger(maximumFindings) || maximumFindings < 1 || maximumFindings > 10000) {
|
|
30
|
+
throw new TypeError('maximumFindings must be an integer between 1 and 10000.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const filter = `select=gt(scene\\,${threshold}),metadata=mode=print:key=lavfi.scene_score`;
|
|
34
|
+
let stderrBytes = 0;
|
|
35
|
+
const result = await runProcess('ffmpeg', [
|
|
36
|
+
'-nostdin', '-hide_banner', '-loglevel', 'info', '-i', media.path,
|
|
37
|
+
'-map', '0:v:0', '-vf', filter, '-an', '-sn', '-dn', '-f', 'null', '-',
|
|
38
|
+
], {
|
|
39
|
+
signal: options.signal,
|
|
40
|
+
stdio: 'capture',
|
|
41
|
+
onStderr: (chunk) => { stderrBytes += chunk.length; },
|
|
42
|
+
});
|
|
43
|
+
if (stderrBytes >= 16 * 1024 * 1024) {
|
|
44
|
+
throw new RangeError('FFmpeg scene analysis exceeded the safe log size. Raise the threshold or analyze a shorter source range.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parsed = parseSceneDetectionOutput(result.stderr, media.durationSeconds);
|
|
48
|
+
const findings = suppressNearbyCuts(parsed, minimumSpacingSeconds);
|
|
49
|
+
if (findings.length > maximumFindings) {
|
|
50
|
+
throw new RangeError(`Scene detection found more than ${maximumFindings} cuts; increase maximumFindings or raise the threshold.`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
providerId: 'ffmpeg-scene-detection',
|
|
55
|
+
analysis: {
|
|
56
|
+
schemaVersion: 1,
|
|
57
|
+
durationSeconds: media.durationSeconds,
|
|
58
|
+
findings: findings.map((finding, index) => ({
|
|
59
|
+
id: `finding-${String(index + 1).padStart(3, '0')}`,
|
|
60
|
+
type: 'scene-cut',
|
|
61
|
+
startSeconds: finding.startSeconds,
|
|
62
|
+
confidence: finding.confidence,
|
|
63
|
+
description: `FFmpeg scene-change score ${finding.confidence.toFixed(3)}. Review this candidate boundary.`,
|
|
64
|
+
})),
|
|
65
|
+
},
|
|
66
|
+
settings: { threshold, minimumSpacingSeconds },
|
|
67
|
+
reviewRequired: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseSceneDetectionOutput(log, durationSeconds) {
|
|
72
|
+
if (typeof log !== 'string' || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
73
|
+
throw new TypeError('Scene detection output requires a log string and positive duration.');
|
|
74
|
+
}
|
|
75
|
+
const frames = [...log.matchAll(/frame:\s*\d+\s+pts:\s*-?\d+\s+pts_time:\s*(-?(?:\d+(?:\.\d*)?|\.\d+))/g)];
|
|
76
|
+
const findings = [];
|
|
77
|
+
for (let index = 0; index < frames.length; index++) {
|
|
78
|
+
const frame = frames[index];
|
|
79
|
+
const blockStart = frame.index + frame[0].length;
|
|
80
|
+
const blockEnd = index + 1 < frames.length ? frames[index + 1].index : log.length;
|
|
81
|
+
const scoreMatch = /lavfi\.scene_score\s*=\s*((?:\d+(?:\.\d*)?|\.\d+))/.exec(log.slice(blockStart, blockEnd));
|
|
82
|
+
if (!scoreMatch) continue;
|
|
83
|
+
const startSeconds = Number(frame[1]);
|
|
84
|
+
const confidence = Number(scoreMatch[1]);
|
|
85
|
+
if (!Number.isFinite(startSeconds) || startSeconds < 0 || startSeconds >= durationSeconds
|
|
86
|
+
|| !Number.isFinite(confidence) || confidence < 0 || confidence > 1) continue;
|
|
87
|
+
findings.push({ startSeconds: round(startSeconds), confidence: round(confidence) });
|
|
88
|
+
}
|
|
89
|
+
return findings;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function suppressNearbyCuts(findings, minimumSpacingSeconds) {
|
|
93
|
+
const kept = [];
|
|
94
|
+
for (const finding of findings) {
|
|
95
|
+
const previous = kept.at(-1);
|
|
96
|
+
if (previous && finding.startSeconds - previous.startSeconds < minimumSpacingSeconds) {
|
|
97
|
+
if (finding.confidence > previous.confidence) kept[kept.length - 1] = finding;
|
|
98
|
+
} else {
|
|
99
|
+
kept.push(finding);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return kept;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function round(value) {
|
|
106
|
+
return Number(value.toFixed(3));
|
|
107
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { copyFile, mkdir, mkdtemp, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { runProcess } from '../render/process.mjs';
|
|
7
|
+
import { detectSceneChangesAsync } from './scene-detection.mjs';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_THUMBNAIL_SIZE = 320;
|
|
10
|
+
const DEFAULT_CONTACT_SHEET_LIMIT = 60;
|
|
11
|
+
const DEFAULT_MAXIMUM_SHOTS = 300;
|
|
12
|
+
const CONTACT_SHEET_PIXEL_LIMIT = 64_000_000;
|
|
13
|
+
|
|
14
|
+
/** Create a review-ready shot map with representative frames and a contact sheet. */
|
|
15
|
+
export async function createSceneReviewPackAsync(inputPath, outputDirectory, options = {}) {
|
|
16
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
17
|
+
throw new TypeError('Scene review options must be an object.');
|
|
18
|
+
}
|
|
19
|
+
const allowedOptions = new Set([
|
|
20
|
+
'threshold', 'minimumSpacingSeconds', 'maximumFindings', 'maximumShots',
|
|
21
|
+
'thumbnailSize', 'contactSheetLimit', 'columns', 'signal', 'onProgress',
|
|
22
|
+
]);
|
|
23
|
+
for (const key of Object.keys(options)) {
|
|
24
|
+
if (!allowedOptions.has(key)) throw new TypeError(`Unsupported scene review option: ${key}.`);
|
|
25
|
+
}
|
|
26
|
+
if (options.onProgress !== undefined && typeof options.onProgress !== 'function') {
|
|
27
|
+
throw new TypeError('onProgress must be a function.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const output = resolve(outputDirectory);
|
|
31
|
+
if (existsSync(output)) throw new Error(`Scene review output already exists: ${output}`);
|
|
32
|
+
const maximumShots = options.maximumShots ?? DEFAULT_MAXIMUM_SHOTS;
|
|
33
|
+
const thumbnailSize = options.thumbnailSize ?? DEFAULT_THUMBNAIL_SIZE;
|
|
34
|
+
const contactSheetLimit = options.contactSheetLimit ?? DEFAULT_CONTACT_SHEET_LIMIT;
|
|
35
|
+
const columns = options.columns ?? 4;
|
|
36
|
+
validateIntegerRange(maximumShots, 1, 2000, 'maximumShots');
|
|
37
|
+
validateIntegerRange(thumbnailSize, 80, 1280, 'thumbnailSize');
|
|
38
|
+
validateIntegerRange(contactSheetLimit, 1, 120, 'contactSheetLimit');
|
|
39
|
+
validateIntegerRange(columns, 1, 8, 'columns');
|
|
40
|
+
|
|
41
|
+
emitProgress(options.onProgress, { stage: 'scene-source-fingerprint', phase: 'started' });
|
|
42
|
+
const sourceSha256 = await hashFileAsync(resolve(inputPath), options.signal);
|
|
43
|
+
emitProgress(options.onProgress, { stage: 'scene-source-fingerprint', phase: 'completed' });
|
|
44
|
+
emitProgress(options.onProgress, { stage: 'scene-analysis', phase: 'started' });
|
|
45
|
+
const analysis = await detectSceneChangesAsync(inputPath, {
|
|
46
|
+
...(options.threshold !== undefined ? { threshold: options.threshold } : {}),
|
|
47
|
+
...(options.minimumSpacingSeconds !== undefined ? { minimumSpacingSeconds: options.minimumSpacingSeconds } : {}),
|
|
48
|
+
...(options.maximumFindings !== undefined ? { maximumFindings: options.maximumFindings } : {}),
|
|
49
|
+
signal: options.signal,
|
|
50
|
+
});
|
|
51
|
+
emitProgress(options.onProgress, { stage: 'scene-analysis', phase: 'completed', completed: 1, total: 1 });
|
|
52
|
+
const shots = buildShotRanges(analysis);
|
|
53
|
+
if (shots.length > maximumShots) {
|
|
54
|
+
throw new RangeError(`Scene review found ${shots.length} shots, above maximumShots ${maximumShots}. Raise minimumSpacingSeconds or increase maximumShots.`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const parent = dirname(output);
|
|
58
|
+
await mkdir(parent, { recursive: true });
|
|
59
|
+
const staging = await mkdtemp(join(parent, `.${basename(output)}-${randomUUID()}-`));
|
|
60
|
+
try {
|
|
61
|
+
const thumbnailDirectory = join(staging, 'thumbnails');
|
|
62
|
+
await mkdir(thumbnailDirectory);
|
|
63
|
+
emitProgress(options.onProgress, { stage: 'scene-thumbnails', phase: 'started', completed: 0, total: shots.length });
|
|
64
|
+
for (const shot of shots) {
|
|
65
|
+
throwIfAborted(options.signal);
|
|
66
|
+
const thumbnailName = `shot-${String(shot.index).padStart(4, '0')}.jpg`;
|
|
67
|
+
const thumbnailPath = join(thumbnailDirectory, thumbnailName);
|
|
68
|
+
const fit = `scale=${thumbnailSize}:${thumbnailSize}:force_original_aspect_ratio=decrease,pad=${thumbnailSize}:${thumbnailSize}:(ow-iw)/2:(oh-ih)/2:color=black`;
|
|
69
|
+
await runProcess('ffmpeg', [
|
|
70
|
+
'-nostdin', '-hide_banner', '-loglevel', 'error', '-ss', String(shot.previewSeconds),
|
|
71
|
+
'-i', resolve(inputPath), '-map', '0:v:0', '-frames:v', '1', '-vf', fit,
|
|
72
|
+
'-q:v', '3', thumbnailPath,
|
|
73
|
+
], { signal: options.signal });
|
|
74
|
+
if ((await stat(thumbnailPath)).size === 0) throw new Error(`FFmpeg produced an empty thumbnail for shot ${shot.index}.`);
|
|
75
|
+
shot.thumbnail = `thumbnails/${thumbnailName}`;
|
|
76
|
+
emitProgress(options.onProgress, {
|
|
77
|
+
stage: 'scene-thumbnails',
|
|
78
|
+
phase: 'progress',
|
|
79
|
+
completed: shot.index,
|
|
80
|
+
total: shots.length,
|
|
81
|
+
percent: shot.index / shots.length * 100,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
emitProgress(options.onProgress, { stage: 'scene-thumbnails', phase: 'completed', completed: shots.length, total: shots.length });
|
|
85
|
+
|
|
86
|
+
const sheetShots = sampleShots(shots, contactSheetLimit);
|
|
87
|
+
const sheetColumns = Math.min(columns, sheetShots.length);
|
|
88
|
+
const sheetRows = Math.ceil(sheetShots.length / sheetColumns);
|
|
89
|
+
const sheetWidth = sheetColumns * thumbnailSize + (sheetColumns + 1) * 12;
|
|
90
|
+
const sheetHeight = sheetRows * thumbnailSize + (sheetRows + 1) * 12;
|
|
91
|
+
if (sheetWidth * sheetHeight > CONTACT_SHEET_PIXEL_LIMIT) {
|
|
92
|
+
throw new RangeError(`Contact sheet would exceed ${CONTACT_SHEET_PIXEL_LIMIT} pixels. Reduce thumbnailSize, contactSheetLimit, or columns.`);
|
|
93
|
+
}
|
|
94
|
+
const sheetInputDirectory = join(staging, 'contact-input');
|
|
95
|
+
await mkdir(sheetInputDirectory);
|
|
96
|
+
for (const [index, shot] of sheetShots.entries()) {
|
|
97
|
+
throwIfAborted(options.signal);
|
|
98
|
+
await copyFile(
|
|
99
|
+
join(staging, shot.thumbnail),
|
|
100
|
+
join(sheetInputDirectory, `frame-${String(index + 1).padStart(4, '0')}.jpg`),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const pattern = join(sheetInputDirectory, 'frame-%04d.jpg');
|
|
104
|
+
const sheetPath = join(staging, 'contact-sheet.jpg');
|
|
105
|
+
const sheetFilter = `tile=${sheetColumns}x${sheetRows}:padding=12:margin=12:color=white`;
|
|
106
|
+
emitProgress(options.onProgress, { stage: 'scene-contact-sheet', phase: 'started' });
|
|
107
|
+
await runProcess('ffmpeg', [
|
|
108
|
+
'-nostdin', '-hide_banner', '-loglevel', 'error', '-framerate', '1', '-start_number', '1',
|
|
109
|
+
'-i', pattern, '-vf', sheetFilter, '-frames:v', '1', '-q:v', '3', sheetPath,
|
|
110
|
+
], { signal: options.signal });
|
|
111
|
+
if ((await stat(sheetPath)).size === 0) throw new Error('FFmpeg produced an empty scene contact sheet.');
|
|
112
|
+
await rm(sheetInputDirectory, { recursive: true, force: true });
|
|
113
|
+
emitProgress(options.onProgress, { stage: 'scene-contact-sheet', phase: 'completed' });
|
|
114
|
+
|
|
115
|
+
const result = {
|
|
116
|
+
schemaVersion: 2,
|
|
117
|
+
sourceSha256,
|
|
118
|
+
providerId: analysis.providerId,
|
|
119
|
+
durationSeconds: analysis.analysis.durationSeconds,
|
|
120
|
+
settings: analysis.settings,
|
|
121
|
+
reviewRequired: true,
|
|
122
|
+
shotCount: shots.length,
|
|
123
|
+
contactSheet: 'contact-sheet.jpg',
|
|
124
|
+
contactSheetShotIndexes: sheetShots.map(({ index }) => index),
|
|
125
|
+
shots,
|
|
126
|
+
analysis: analysis.analysis,
|
|
127
|
+
};
|
|
128
|
+
await writeFile(join(staging, 'scene-review.json'), `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' });
|
|
129
|
+
throwIfAborted(options.signal);
|
|
130
|
+
emitProgress(options.onProgress, { stage: 'scene-review-publish', phase: 'started' });
|
|
131
|
+
await rename(staging, output);
|
|
132
|
+
emitProgress(options.onProgress, { stage: 'scene-review-publish', phase: 'completed' });
|
|
133
|
+
return { ...result, outputDirectory: output };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
emitProgress(options.onProgress, { stage: 'scene-review-pack', phase: 'failed', message: error.message });
|
|
136
|
+
await rm(staging, { recursive: true, force: true });
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function buildShotRanges(analysis) {
|
|
142
|
+
const durationSeconds = analysis?.analysis?.durationSeconds;
|
|
143
|
+
const findings = analysis?.analysis?.findings;
|
|
144
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0 || !Array.isArray(findings)) {
|
|
145
|
+
throw new TypeError('Scene analysis must contain a positive duration and findings array.');
|
|
146
|
+
}
|
|
147
|
+
const boundaries = [0, ...findings
|
|
148
|
+
.filter((finding) => finding.type === 'scene-cut')
|
|
149
|
+
.map((finding) => finding.startSeconds)
|
|
150
|
+
.filter((time) => Number.isFinite(time) && time > 0 && time < durationSeconds), durationSeconds]
|
|
151
|
+
.sort((left, right) => left - right)
|
|
152
|
+
.filter((time, index, all) => index === 0 || time - all[index - 1] > 1e-6);
|
|
153
|
+
|
|
154
|
+
return boundaries.slice(0, -1).map((startSeconds, index) => {
|
|
155
|
+
const endSeconds = boundaries[index + 1];
|
|
156
|
+
const previewSeconds = startSeconds + Math.min(0.5, (endSeconds - startSeconds) / 2);
|
|
157
|
+
return {
|
|
158
|
+
index: index + 1,
|
|
159
|
+
startSeconds: round(startSeconds),
|
|
160
|
+
endSeconds: round(endSeconds),
|
|
161
|
+
durationSeconds: round(endSeconds - startSeconds),
|
|
162
|
+
previewSeconds: round(previewSeconds),
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sampleShots(shots, limit) {
|
|
168
|
+
if (shots.length <= limit) return shots;
|
|
169
|
+
if (limit === 1) return [shots[0]];
|
|
170
|
+
const selected = new Map();
|
|
171
|
+
for (let index = 0; index < limit; index += 1) {
|
|
172
|
+
const sourceIndex = Math.round(index * (shots.length - 1) / (limit - 1));
|
|
173
|
+
selected.set(shots[sourceIndex].index, shots[sourceIndex]);
|
|
174
|
+
}
|
|
175
|
+
return [...selected.values()];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function validateIntegerRange(value, minimum, maximum, name) {
|
|
179
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
180
|
+
throw new TypeError(`${name} must be an integer between ${minimum} and ${maximum}.`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function throwIfAborted(signal) {
|
|
185
|
+
if (signal?.aborted) {
|
|
186
|
+
if (signal.reason instanceof Error) throw signal.reason;
|
|
187
|
+
const error = new Error('Scene review cancelled.');
|
|
188
|
+
error.name = 'AbortError';
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function emitProgress(onProgress, event) {
|
|
194
|
+
try { onProgress?.({ ...event }); } catch { /* Progress observers must not interrupt media processing. */ }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function hashFileAsync(path, signal) {
|
|
198
|
+
const hash = createHash('sha256');
|
|
199
|
+
for await (const chunk of createReadStream(path, { signal })) hash.update(chunk);
|
|
200
|
+
return hash.digest('hex');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function round(value) {
|
|
204
|
+
return Number(value.toFixed(6));
|
|
205
|
+
}
|