aivee 0.1.0-alpha.0 → 0.1.0-dev.59872e1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE +21 -21
  3. package/README.md +120 -118
  4. package/docs/architecture.md +401 -401
  5. package/docs/aspect-ratio-presets.md +72 -72
  6. package/docs/releasing.md +19 -19
  7. package/docs/usage.md +1193 -1193
  8. package/package.json +52 -52
  9. package/requirements-otio.txt +1 -1
  10. package/src/capabilities.mjs +307 -307
  11. package/src/cli.mjs +646 -646
  12. package/src/doctor.mjs +133 -133
  13. package/src/edits/analysis-to-edl.mjs +43 -43
  14. package/src/edits/detect-silence.mjs +51 -51
  15. package/src/edits/find-mistake-candidates.mjs +42 -42
  16. package/src/edits/plan-shot-placements.mjs +105 -105
  17. package/src/edits/plan-silence-cuts.mjs +49 -49
  18. package/src/edits/retime-transcript.mjs +105 -105
  19. package/src/files.mjs +80 -80
  20. package/src/index.mjs +44 -44
  21. package/src/interchange/otio.mjs +204 -204
  22. package/src/interchange/otio_bridge.py +521 -521
  23. package/src/media/analysis-provider.mjs +75 -75
  24. package/src/media/compatibility.mjs +45 -45
  25. package/src/media/ffmpeg-capabilities.mjs +129 -129
  26. package/src/media/frame-rate.mjs +58 -58
  27. package/src/media/frame-time.mjs +22 -22
  28. package/src/media/preview-assets.mjs +350 -350
  29. package/src/media/probe.mjs +149 -149
  30. package/src/media/render-cuts.mjs +90 -90
  31. package/src/media/scene-detection.mjs +107 -107
  32. package/src/media/scene-review.mjs +205 -205
  33. package/src/project/assets.mjs +1194 -1194
  34. package/src/project/create.mjs +100 -100
  35. package/src/project/presets.mjs +44 -44
  36. package/src/render/checkpoints.mjs +192 -192
  37. package/src/render/formats.mjs +163 -163
  38. package/src/render/hyperframes.mjs +590 -590
  39. package/src/render/process.mjs +111 -111
  40. package/src/render/progress.mjs +46 -46
  41. package/src/render/timeline-control.mjs +53 -53
  42. package/src/timeline/assemble.mjs +119 -119
  43. package/src/timeline/audio-effects.mjs +75 -75
  44. package/src/timeline/audio-mastering.mjs +236 -236
  45. package/src/timeline/audio-mix.mjs +105 -105
  46. package/src/timeline/audio-track-effects.mjs +139 -139
  47. package/src/timeline/audio-volume.mjs +63 -63
  48. package/src/timeline/background.mjs +8 -8
  49. package/src/timeline/blend-mode.mjs +34 -34
  50. package/src/timeline/caption-layouts.mjs +62 -61
  51. package/src/timeline/captions.mjs +80 -80
  52. package/src/timeline/edit.mjs +965 -965
  53. package/src/timeline/fingerprint.mjs +6 -6
  54. package/src/timeline/from-edl.mjs +31 -31
  55. package/src/timeline/history-store.mjs +281 -281
  56. package/src/timeline/history.mjs +69 -69
  57. package/src/timeline/inspect.mjs +117 -117
  58. package/src/timeline/keyframes.mjs +158 -158
  59. package/src/timeline/layers.mjs +18 -18
  60. package/src/timeline/mask.mjs +53 -53
  61. package/src/timeline/project.mjs +52 -52
  62. package/src/timeline/render.mjs +759 -759
  63. package/src/timeline/sidechain-render.mjs +209 -209
  64. package/src/timeline/source-fit.mjs +15 -15
  65. package/src/timeline/speech-bubble.mjs +26 -26
  66. package/src/timeline/speed-ramp.mjs +188 -188
  67. package/src/timeline/track-state.mjs +22 -22
  68. package/src/timeline/transition.mjs +34 -34
  69. package/src/timeline/validate.mjs +322 -322
  70. package/src/transcript/corrections.mjs +21 -21
  71. package/src/transcript/normalize.mjs +17 -17
  72. package/src/transcript/provider.mjs +42 -42
  73. package/src/transcript/subtitles.mjs +57 -57
  74. 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
+ }