aivee 0.1.0-alpha.0 → 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 -21
- package/README.md +118 -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 +61 -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,350 +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
|
-
}
|
|
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
|
+
}
|