aivee 0.1.0-alpha.0 → 0.1.0-dev.d34a6ba

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