aivee 0.0.0-stage → 0.1.0-dev.7025438
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 -0
- package/README.md +117 -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 +61 -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,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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { frameRateNumber } from './frame-rate.mjs';
|
|
2
|
+
|
|
3
|
+
export function secondsToFrame(seconds, fps, rounding = 'nearest') {
|
|
4
|
+
if (!Number.isFinite(seconds) || seconds < 0) throw new TypeError('Time in seconds must be a non-negative finite number.');
|
|
5
|
+
const rate = frameRateNumber(fps);
|
|
6
|
+
const position = seconds * rate;
|
|
7
|
+
const frame = rounding === 'nearest' ? Math.round(position)
|
|
8
|
+
: rounding === 'floor' ? Math.floor(position)
|
|
9
|
+
: rounding === 'ceil' ? Math.ceil(position)
|
|
10
|
+
: NaN;
|
|
11
|
+
if (!Number.isSafeInteger(frame)) throw new TypeError('Frame rounding must be nearest, floor, or ceil.');
|
|
12
|
+
return frame;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function frameToSeconds(frame, fps) {
|
|
16
|
+
if (!Number.isSafeInteger(frame) || frame < 0) throw new TypeError('Frame must be a non-negative safe integer.');
|
|
17
|
+
return frame / frameRateNumber(fps);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function snapTimeToFrame(seconds, fps, rounding = 'nearest') {
|
|
21
|
+
return frameToSeconds(secondsToFrame(seconds, fps, rounding), fps);
|
|
22
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { copyFile, rename, rm } from 'node:fs/promises';
|
|
5
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { probeMediaAsync } from './probe.mjs';
|
|
7
|
+
import { runProcess } from '../render/process.mjs';
|
|
8
|
+
|
|
9
|
+
const WAVEFORM_SAMPLE_RATE = 4000;
|
|
10
|
+
const MAX_THUMBNAILS = 20000;
|
|
11
|
+
|
|
12
|
+
export async function generateAssetPreview(projectPath, assetId, options = {}) {
|
|
13
|
+
const { signal, onProgress, ...previewOptions } = options;
|
|
14
|
+
validateAbortSignal(signal);
|
|
15
|
+
if (onProgress !== undefined && typeof onProgress !== 'function') throw new TypeError('Preview onProgress must be a function.');
|
|
16
|
+
const report = (event) => {
|
|
17
|
+
try { onProgress?.({ assetId, ...event }); } catch { /* Progress observers must not interrupt media processing. */ }
|
|
18
|
+
};
|
|
19
|
+
report({ stage: 'preview-preflight', phase: 'started' });
|
|
20
|
+
report({ stage: 'preview', phase: 'started' });
|
|
21
|
+
try {
|
|
22
|
+
return await generateAssetPreviewWithProgress(projectPath, assetId, previewOptions, signal, report);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
report({ stage: 'preview', phase: 'failed', message: error.message });
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function generateAssetPreviewWithProgress(projectPath, assetId, previewOptions, signal, report) {
|
|
30
|
+
const root = resolve(projectPath);
|
|
31
|
+
if (!existsSync(join(root, 'meta.json'))) throw new Error(`Not an Engine project: ${root}`);
|
|
32
|
+
if (!/^[a-z][a-z0-9-]*$/.test(assetId ?? '')) throw new TypeError('Asset id must be a lowercase kebab-case identifier.');
|
|
33
|
+
const thumbnailIntervalSeconds = previewOptions.thumbnailIntervalSeconds ?? 2;
|
|
34
|
+
const thumbnailMaxDimension = previewOptions.thumbnailMaxDimension ?? 320;
|
|
35
|
+
const waveformPoints = previewOptions.waveformPoints ?? 2000;
|
|
36
|
+
if (!Number.isFinite(thumbnailIntervalSeconds) || thumbnailIntervalSeconds < 0.25 || thumbnailIntervalSeconds > 60) {
|
|
37
|
+
throw new TypeError('thumbnailIntervalSeconds must be from 0.25 to 60.');
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isSafeInteger(thumbnailMaxDimension) || thumbnailMaxDimension < 80 || thumbnailMaxDimension > 1280) {
|
|
40
|
+
throw new TypeError('thumbnailMaxDimension must be an integer from 80 to 1280.');
|
|
41
|
+
}
|
|
42
|
+
if (!Number.isSafeInteger(waveformPoints) || waveformPoints < 32 || waveformPoints > 20000) {
|
|
43
|
+
throw new TypeError('waveformPoints must be an integer from 32 to 20000.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const manifestPath = join(root, 'assets', 'manifest.json');
|
|
47
|
+
const assetsDirectory = join(root, 'assets');
|
|
48
|
+
if (!existsSync(assetsDirectory) || !statSync(assetsDirectory).isDirectory() || lstatSync(assetsDirectory).isSymbolicLink()) throw new Error('Project assets directory is missing or unsafe.');
|
|
49
|
+
if (!existsSync(manifestPath) || lstatSync(manifestPath).isSymbolicLink()) throw new Error('Project asset manifest is missing or unsafe.');
|
|
50
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
51
|
+
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.assets)) throw new TypeError('Asset manifest must use schemaVersion 1 and an assets array.');
|
|
52
|
+
const asset = manifest.assets.find((entry) => entry?.id === assetId);
|
|
53
|
+
if (!asset) throw new Error(`Asset is not registered: ${assetId}`);
|
|
54
|
+
if (!['video', 'audio', 'image'].includes(asset.type) || !/^[\da-f]{64}$/.test(asset.sha256 ?? '')) throw new TypeError(`Asset manifest entry is invalid: ${assetId}`);
|
|
55
|
+
const assetParts = typeof asset.path === 'string' ? asset.path.split('/') : [];
|
|
56
|
+
if (assetParts.length !== 2 || assetParts[0] !== 'assets' || asset.path.includes('\\') || assetParts.some((part) => !part || part === '.' || part === '..')) {
|
|
57
|
+
throw new TypeError(`Asset path is invalid: ${assetId}`);
|
|
58
|
+
}
|
|
59
|
+
const source = resolve(root, asset.path);
|
|
60
|
+
const sourceRelative = relative(root, source);
|
|
61
|
+
if (!sourceRelative || sourceRelative.startsWith('..') || sourceRelative.includes(':') || asset.path.includes('\\')) throw new TypeError(`Asset path is invalid: ${assetId}`);
|
|
62
|
+
if (!existsSync(source) || !statSync(source).isFile() || lstatSync(source).isSymbolicLink()) throw new Error(`Asset ${assetId} is missing or unsafe.`);
|
|
63
|
+
const sourceSize = statSync(source).size;
|
|
64
|
+
let hashedBytes = 0;
|
|
65
|
+
let reportedHashPercent = -1;
|
|
66
|
+
report({ stage: 'preview-integrity', phase: 'started', totalBytes: sourceSize });
|
|
67
|
+
const sourceSha256 = await hashFileAsync(source, signal, (bytes) => {
|
|
68
|
+
hashedBytes += bytes;
|
|
69
|
+
const percent = sourceSize ? Math.min(100, Math.floor(hashedBytes / sourceSize * 100)) : 100;
|
|
70
|
+
if (percent > reportedHashPercent) {
|
|
71
|
+
reportedHashPercent = percent;
|
|
72
|
+
report({ stage: 'preview-integrity', phase: 'progress', currentBytes: hashedBytes, totalBytes: sourceSize, percent });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
report({ stage: 'preview-integrity', phase: 'completed', currentBytes: sourceSize, totalBytes: sourceSize, percent: 100 });
|
|
76
|
+
if (sourceSha256 !== asset.sha256) throw new Error(`Asset ${assetId} failed integrity verification; relink it before preview generation.`);
|
|
77
|
+
signal?.throwIfAborted();
|
|
78
|
+
|
|
79
|
+
const isImage = asset.type === 'image';
|
|
80
|
+
report({ stage: 'preview-probe', phase: isImage ? 'skipped' : 'started', ...(isImage ? { reason: 'image asset' } : {}) });
|
|
81
|
+
const media = isImage ? undefined : await probeMediaAsync(source, { requireVideo: false, signal });
|
|
82
|
+
if (!isImage) report({ stage: 'preview-probe', phase: 'completed' });
|
|
83
|
+
const hasVideo = isImage || Number.isFinite(media?.width);
|
|
84
|
+
const hasAudio = !isImage && media?.hasAudio === true;
|
|
85
|
+
const durationSeconds = isImage ? 0 : media.durationSeconds;
|
|
86
|
+
if (hasVideo && !isImage && (!Number.isFinite(durationSeconds) || durationSeconds <= 0)) throw new Error(`Video asset ${assetId} has no usable duration.`);
|
|
87
|
+
const settings = { thumbnailIntervalSeconds, thumbnailMaxDimension, waveformPoints };
|
|
88
|
+
const key = createHash('sha256').update(JSON.stringify({ sourceSha256, settings })).digest('hex').slice(0, 20);
|
|
89
|
+
const previewRoot = join(root, 'assets', 'previews');
|
|
90
|
+
if (existsSync(previewRoot) && (!statSync(previewRoot).isDirectory() || lstatSync(previewRoot).isSymbolicLink())) throw new Error('Preview cache directory is unsafe.');
|
|
91
|
+
mkdirSync(previewRoot, { recursive: true });
|
|
92
|
+
const destination = join(previewRoot, `${assetId}-${key}`);
|
|
93
|
+
const expected = { assetId, sourceSha256, settings, durationSeconds, isImage, hasVideo, hasAudio };
|
|
94
|
+
report({ stage: 'preview-preflight', phase: 'completed' });
|
|
95
|
+
if (existsSync(destination)) {
|
|
96
|
+
report({ stage: 'preview-cache', phase: 'started' });
|
|
97
|
+
const cached = await readCachedPreview(destination, expected, signal);
|
|
98
|
+
report({ stage: 'preview-cache', phase: 'completed', result: 'hit' });
|
|
99
|
+
report({ stage: 'preview-generation', phase: 'skipped', reason: 'cache hit' });
|
|
100
|
+
report({ stage: 'preview', phase: 'completed', result: 'cached' });
|
|
101
|
+
return cached;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const staging = join(previewRoot, `.preview-${randomUUID()}.tmp`);
|
|
105
|
+
mkdirSync(staging, { recursive: false });
|
|
106
|
+
try {
|
|
107
|
+
const thumbnails = [];
|
|
108
|
+
if (hasVideo) {
|
|
109
|
+
const count = isImage ? 1 : Math.max(1, Math.ceil(durationSeconds / thumbnailIntervalSeconds - 1e-9));
|
|
110
|
+
if (count > MAX_THUMBNAILS) throw new RangeError(`Preview would create ${count} thumbnails; increase thumbnailIntervalSeconds to stay within ${MAX_THUMBNAILS}.`);
|
|
111
|
+
const pattern = join(staging, 'thumbnail-%05d.jpg');
|
|
112
|
+
report({ stage: 'preview-thumbnails', phase: 'started', total: count });
|
|
113
|
+
if (isImage) {
|
|
114
|
+
await runFfmpeg(['-i', source, '-frames:v', '1', '-vf', `scale=${thumbnailMaxDimension}:${thumbnailMaxDimension}:force_original_aspect_ratio=decrease`, '-q:v', '3', join(staging, 'thumbnail-00001.jpg')], signal, { onProgress: report, durationSeconds: 0 });
|
|
115
|
+
} else {
|
|
116
|
+
await runFfmpeg(['-i', source, '-map', '0:v:0', '-vf', `fps=1/${thumbnailIntervalSeconds},scale=${thumbnailMaxDimension}:${thumbnailMaxDimension}:force_original_aspect_ratio=decrease`, '-frames:v', String(count), '-q:v', '3', pattern], signal, { onProgress: report, durationSeconds });
|
|
117
|
+
}
|
|
118
|
+
for (let index = 1; index <= count; index++) {
|
|
119
|
+
const file = `thumbnail-${String(index).padStart(5, '0')}.jpg`;
|
|
120
|
+
const path = join(staging, file);
|
|
121
|
+
if (!existsSync(path) || statSync(path).size === 0) throw new Error(`FFmpeg did not create expected preview frame ${index} for ${assetId}.`);
|
|
122
|
+
thumbnails.push({ timeSeconds: isImage ? 0 : Number(((index - 1) * thumbnailIntervalSeconds).toFixed(6)), file, sha256: await hashFileAsync(path, signal) });
|
|
123
|
+
report({ stage: 'preview-thumbnails', phase: 'progress', current: index, total: count, percent: index / count * 100 });
|
|
124
|
+
}
|
|
125
|
+
report({ stage: 'preview-thumbnails', phase: 'completed', current: count, total: count, percent: 100 });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let waveform;
|
|
129
|
+
if (hasAudio) {
|
|
130
|
+
report({ stage: 'preview-waveform', phase: 'started', totalSamples: Math.ceil(durationSeconds * WAVEFORM_SAMPLE_RATE) });
|
|
131
|
+
waveform = await createWaveform(source, durationSeconds, waveformPoints, signal, report);
|
|
132
|
+
writeFileSync(join(staging, 'waveform.json'), `${JSON.stringify({ sampleRate: waveform.sampleRate, points: waveform.points })}\n`, { flag: 'wx' });
|
|
133
|
+
report({ stage: 'preview-waveform', phase: 'completed', currentSamples: waveform.sampleCount, percent: 100 });
|
|
134
|
+
}
|
|
135
|
+
const result = {
|
|
136
|
+
schemaVersion: 1,
|
|
137
|
+
assetId,
|
|
138
|
+
sourceSha256,
|
|
139
|
+
durationSeconds,
|
|
140
|
+
settings,
|
|
141
|
+
thumbnails: thumbnails.map(({ timeSeconds, file }) => ({ timeSeconds, path: `assets/previews/${assetId}-${key}/${file}` })),
|
|
142
|
+
...(waveform ? { waveform: { path: `assets/previews/${assetId}-${key}/waveform.json`, sampleRate: waveform.sampleRate, points: waveform.points } } : {}),
|
|
143
|
+
};
|
|
144
|
+
writeFileSync(join(staging, 'preview.json'), `${JSON.stringify({ ...result, files: [
|
|
145
|
+
...thumbnails.map(({ file, sha256 }) => ({ file, sha256 })),
|
|
146
|
+
...(waveform ? [{ file: 'waveform.json', sha256: await hashFileAsync(join(staging, 'waveform.json'), signal) }] : []),
|
|
147
|
+
] }, null, 2)}\n`, { flag: 'wx' });
|
|
148
|
+
signal?.throwIfAborted();
|
|
149
|
+
report({ stage: 'preview-publish', phase: 'started' });
|
|
150
|
+
try {
|
|
151
|
+
await rename(staging, destination);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (existsSync(destination)) {
|
|
154
|
+
await rm(staging, { recursive: true, force: true });
|
|
155
|
+
const cached = await readCachedPreview(destination, expected, signal);
|
|
156
|
+
report({ stage: 'preview-publish', phase: 'skipped', reason: 'another process published matching preview' });
|
|
157
|
+
report({ stage: 'preview', phase: 'completed', result: 'cached' });
|
|
158
|
+
return cached;
|
|
159
|
+
}
|
|
160
|
+
if (!['EPERM', 'EXDEV'].includes(error.code)) throw error;
|
|
161
|
+
mkdirSync(destination, { recursive: false });
|
|
162
|
+
try {
|
|
163
|
+
for (const file of readdirSync(staging)) {
|
|
164
|
+
signal?.throwIfAborted();
|
|
165
|
+
await copyFile(join(staging, file), join(destination, file));
|
|
166
|
+
}
|
|
167
|
+
await rm(staging, { recursive: true, force: true });
|
|
168
|
+
} catch (copyError) {
|
|
169
|
+
await rm(destination, { recursive: true, force: true });
|
|
170
|
+
throw copyError;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
report({ stage: 'preview-publish', phase: 'completed' });
|
|
174
|
+
report({ stage: 'preview', phase: 'completed', result: 'generated' });
|
|
175
|
+
return result;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
await rm(staging, { recursive: true, force: true });
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function createWaveform(source, durationSeconds, pointCount, signal, onProgress) {
|
|
183
|
+
signal?.throwIfAborted();
|
|
184
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
185
|
+
const child = spawn('ffmpeg', [
|
|
186
|
+
'-nostdin', '-hide_banner', '-v', 'error', '-i', source, '-map', '0:a:0', '-vn',
|
|
187
|
+
'-ac', '1', '-ar', String(WAVEFORM_SAMPLE_RATE), '-f', 'f32le', 'pipe:1',
|
|
188
|
+
], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
189
|
+
const points = Array.from({ length: pointCount }, () => ({ min: 1, max: -1 }));
|
|
190
|
+
let sampleIndex = 0;
|
|
191
|
+
let remainder = Buffer.alloc(0);
|
|
192
|
+
let stderr = '';
|
|
193
|
+
let reportedPercent = -1;
|
|
194
|
+
let settled = false;
|
|
195
|
+
const timer = setTimeout(() => child.kill(), 300000);
|
|
196
|
+
const fail = (error) => {
|
|
197
|
+
if (settled) return;
|
|
198
|
+
settled = true;
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
signal?.removeEventListener('abort', onAbort);
|
|
201
|
+
rejectPromise(error);
|
|
202
|
+
};
|
|
203
|
+
const onAbort = () => child.kill('SIGTERM');
|
|
204
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
205
|
+
child.stdout.on('data', (chunk) => {
|
|
206
|
+
const data = remainder.length ? Buffer.concat([remainder, chunk]) : chunk;
|
|
207
|
+
const usableBytes = data.length - (data.length % 4);
|
|
208
|
+
for (let offset = 0; offset < usableBytes; offset += 4) {
|
|
209
|
+
const sample = Math.max(-1, Math.min(1, data.readFloatLE(offset)));
|
|
210
|
+
const time = sampleIndex / WAVEFORM_SAMPLE_RATE;
|
|
211
|
+
const bucket = Math.min(pointCount - 1, Math.floor(time / durationSeconds * pointCount));
|
|
212
|
+
points[bucket].min = Math.min(points[bucket].min, sample);
|
|
213
|
+
points[bucket].max = Math.max(points[bucket].max, sample);
|
|
214
|
+
sampleIndex++;
|
|
215
|
+
}
|
|
216
|
+
remainder = data.subarray(usableBytes);
|
|
217
|
+
const percent = Math.min(99, Math.floor(sampleIndex / (durationSeconds * WAVEFORM_SAMPLE_RATE) * 100));
|
|
218
|
+
if (percent > reportedPercent) {
|
|
219
|
+
reportedPercent = percent;
|
|
220
|
+
onProgress({ stage: 'preview-waveform', phase: 'progress', currentSamples: sampleIndex, totalSamples: Math.ceil(durationSeconds * WAVEFORM_SAMPLE_RATE), percent });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
child.stderr.on('data', (chunk) => { if (stderr.length < 32768) stderr += chunk.toString(); });
|
|
224
|
+
child.on('error', (error) => fail(new Error(`Could not start FFmpeg waveform preview: ${error.message}`)));
|
|
225
|
+
child.on('close', (code) => {
|
|
226
|
+
if (settled) return;
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
signal?.removeEventListener('abort', onAbort);
|
|
229
|
+
if (signal?.aborted) return fail(signal.reason ?? new DOMException('Asset preview was aborted.', 'AbortError'));
|
|
230
|
+
if (code !== 0) return fail(new Error(`FFmpeg waveform preview failed: ${stderr.trim() || `exit code ${code}`}`));
|
|
231
|
+
if (sampleIndex === 0) return fail(new Error('FFmpeg returned no audio samples for waveform preview.'));
|
|
232
|
+
resolvePromise({ sampleRate: WAVEFORM_SAMPLE_RATE, sampleCount: sampleIndex, points: points.map((point) => ({
|
|
233
|
+
min: point.min === 1 ? 0 : Number(point.min.toFixed(5)),
|
|
234
|
+
max: point.max === -1 ? 0 : Number(point.max.toFixed(5)),
|
|
235
|
+
})) });
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function readCachedPreview(directory, expected, signal) {
|
|
241
|
+
if (!statSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink()) throw new Error('Preview cache entry is unsafe.');
|
|
242
|
+
const recordPath = join(directory, 'preview.json');
|
|
243
|
+
if (!existsSync(recordPath) || lstatSync(recordPath).isSymbolicLink()) throw new Error('Preview cache record is missing or unsafe.');
|
|
244
|
+
const record = JSON.parse(readFileSync(recordPath, 'utf8'));
|
|
245
|
+
if (record.schemaVersion !== 1 || record.assetId !== expected.assetId || record.sourceSha256 !== expected.sourceSha256
|
|
246
|
+
|| JSON.stringify(record.settings) !== JSON.stringify(expected.settings) || record.durationSeconds !== expected.durationSeconds
|
|
247
|
+
|| !Array.isArray(record.files)) {
|
|
248
|
+
throw new Error('Preview cache record does not match its source or settings.');
|
|
249
|
+
}
|
|
250
|
+
const fileNames = new Set();
|
|
251
|
+
for (const file of record.files) {
|
|
252
|
+
signal?.throwIfAborted();
|
|
253
|
+
if (!file || basename(file.file) !== file.file || !/^[\da-f]{64}$/.test(file.sha256 ?? '')) throw new Error('Preview cache file record is invalid.');
|
|
254
|
+
if (fileNames.has(file.file)) throw new Error('Preview cache file records contain duplicates.');
|
|
255
|
+
fileNames.add(file.file);
|
|
256
|
+
const path = join(directory, file.file);
|
|
257
|
+
if (!existsSync(path) || !statSync(path).isFile() || lstatSync(path).isSymbolicLink() || await hashFileAsync(path, signal) !== file.sha256) {
|
|
258
|
+
throw new Error(`Preview cache file is missing or modified: ${file.file}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const previewDirectory = `assets/previews/${basename(directory)}`;
|
|
262
|
+
const expectedThumbnailCount = expected.hasVideo
|
|
263
|
+
? (expected.isImage ? 1 : Math.max(1, Math.ceil(expected.durationSeconds / expected.settings.thumbnailIntervalSeconds - 1e-9)))
|
|
264
|
+
: 0;
|
|
265
|
+
if (expectedThumbnailCount > MAX_THUMBNAILS || record.thumbnails?.length !== expectedThumbnailCount) throw new Error('Preview cache thumbnail count is invalid.');
|
|
266
|
+
if (!Array.isArray(record.thumbnails) || record.thumbnails.some((thumbnail) => !thumbnail
|
|
267
|
+
|| !Number.isFinite(thumbnail.timeSeconds) || !/^thumbnail-\d{5}\.jpg$/.test(basename(thumbnail.path ?? ''))
|
|
268
|
+
|| thumbnail.path !== `${previewDirectory}/${basename(thumbnail.path)}`
|
|
269
|
+
|| !fileNames.has(basename(thumbnail.path)))) throw new Error('Preview cache thumbnail records are invalid.');
|
|
270
|
+
for (const [index, thumbnail] of record.thumbnails.entries()) {
|
|
271
|
+
const expectedTime = expected.isImage ? 0 : Number((index * expected.settings.thumbnailIntervalSeconds).toFixed(6));
|
|
272
|
+
if (thumbnail.timeSeconds !== expectedTime || basename(thumbnail.path) !== `thumbnail-${String(index + 1).padStart(5, '0')}.jpg`) {
|
|
273
|
+
throw new Error('Preview cache thumbnail timing is invalid.');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (Boolean(record.waveform) !== expected.hasAudio) throw new Error('Preview cache waveform presence is invalid.');
|
|
277
|
+
let waveformData;
|
|
278
|
+
if (record.waveform) {
|
|
279
|
+
if (record.waveform.path !== `${previewDirectory}/waveform.json` || !fileNames.has('waveform.json')
|
|
280
|
+
|| record.waveform.sampleRate !== WAVEFORM_SAMPLE_RATE) throw new Error('Preview cache waveform record is invalid.');
|
|
281
|
+
waveformData = JSON.parse(readFileSync(join(directory, 'waveform.json'), 'utf8'));
|
|
282
|
+
if (!Array.isArray(waveformData.points) || waveformData.points.length !== expected.settings.waveformPoints
|
|
283
|
+
|| JSON.stringify(waveformData.points) !== JSON.stringify(record.waveform.points)
|
|
284
|
+
|| waveformData.points.some((point) => !point || !Number.isFinite(point.min) || !Number.isFinite(point.max)
|
|
285
|
+
|| point.min < -1 || point.max > 1 || point.min > point.max)) throw new Error('Preview cache waveform samples are invalid.');
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
schemaVersion: record.schemaVersion,
|
|
289
|
+
assetId: record.assetId,
|
|
290
|
+
sourceSha256: record.sourceSha256,
|
|
291
|
+
durationSeconds: record.durationSeconds,
|
|
292
|
+
settings: record.settings,
|
|
293
|
+
thumbnails: record.thumbnails,
|
|
294
|
+
...(record.waveform ? { waveform: { ...record.waveform, points: waveformData.points } } : {}),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function validateAbortSignal(signal) {
|
|
299
|
+
if (signal !== undefined && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function')) {
|
|
300
|
+
throw new TypeError('Preview signal must be an AbortSignal.');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function runFfmpeg(args, signal, { onProgress, durationSeconds } = {}) {
|
|
305
|
+
const controller = new AbortController();
|
|
306
|
+
const relayAbort = () => controller.abort(signal.reason);
|
|
307
|
+
if (signal?.aborted) relayAbort();
|
|
308
|
+
else signal?.addEventListener('abort', relayAbort, { once: true });
|
|
309
|
+
const timer = setTimeout(() => {
|
|
310
|
+
const error = new Error('FFmpeg thumbnail generation exceeded 120 seconds.');
|
|
311
|
+
error.name = 'TimeoutError';
|
|
312
|
+
controller.abort(error);
|
|
313
|
+
}, 120000);
|
|
314
|
+
try {
|
|
315
|
+
let outputBuffer = '';
|
|
316
|
+
await runProcess('ffmpeg', ['-nostdin', '-hide_banner', '-v', 'error', '-y', '-progress', 'pipe:1', '-nostats', ...args], {
|
|
317
|
+
signal: controller.signal,
|
|
318
|
+
stdio: 'capture',
|
|
319
|
+
onStdout(chunk) {
|
|
320
|
+
outputBuffer += chunk.toString();
|
|
321
|
+
const lines = outputBuffer.split(/\r?\n/);
|
|
322
|
+
outputBuffer = lines.pop() ?? '';
|
|
323
|
+
for (const line of lines) {
|
|
324
|
+
const [key, value] = line.split('=', 2);
|
|
325
|
+
if (key !== 'out_time' || !durationSeconds) continue;
|
|
326
|
+
const match = /^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/.exec(value);
|
|
327
|
+
if (!match) continue;
|
|
328
|
+
const elapsed = Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]) + Number(`0.${match[4] ?? 0}`);
|
|
329
|
+
onProgress({ stage: 'preview-thumbnails', phase: 'progress', percent: Math.min(99, elapsed / durationSeconds * 100) });
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (signal?.aborted || error.name === 'TimeoutError') throw error;
|
|
335
|
+
throw new Error(`FFmpeg thumbnail generation failed: ${error.message}`, { cause: error });
|
|
336
|
+
} finally {
|
|
337
|
+
clearTimeout(timer);
|
|
338
|
+
signal?.removeEventListener('abort', relayAbort);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function hashFileAsync(path, signal, onBytes) {
|
|
343
|
+
const hash = createHash('sha256');
|
|
344
|
+
for await (const chunk of createReadStream(path, { signal })) {
|
|
345
|
+
hash.update(chunk);
|
|
346
|
+
onBytes?.(chunk.length);
|
|
347
|
+
}
|
|
348
|
+
signal?.throwIfAborted();
|
|
349
|
+
return hash.digest('hex');
|
|
350
|
+
}
|