aivee 0.0.0-stage → 0.1.0-dev.7025438

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -2
  3. package/docs/architecture.md +401 -0
  4. package/docs/aspect-ratio-presets.md +72 -0
  5. package/docs/releasing.md +19 -0
  6. package/docs/usage.md +1193 -0
  7. package/package.json +50 -4
  8. package/requirements-otio.txt +1 -0
  9. package/src/capabilities.mjs +307 -0
  10. package/src/cli.mjs +646 -0
  11. package/src/doctor.mjs +133 -0
  12. package/src/edits/analysis-to-edl.mjs +43 -0
  13. package/src/edits/detect-silence.mjs +51 -0
  14. package/src/edits/find-mistake-candidates.mjs +42 -0
  15. package/src/edits/plan-shot-placements.mjs +105 -0
  16. package/src/edits/plan-silence-cuts.mjs +49 -0
  17. package/src/edits/retime-transcript.mjs +105 -0
  18. package/src/files.mjs +80 -0
  19. package/src/index.mjs +44 -0
  20. package/src/interchange/otio.mjs +204 -0
  21. package/src/interchange/otio_bridge.py +521 -0
  22. package/src/media/analysis-provider.mjs +75 -0
  23. package/src/media/compatibility.mjs +45 -0
  24. package/src/media/ffmpeg-capabilities.mjs +129 -0
  25. package/src/media/frame-rate.mjs +58 -0
  26. package/src/media/frame-time.mjs +22 -0
  27. package/src/media/preview-assets.mjs +350 -0
  28. package/src/media/probe.mjs +149 -0
  29. package/src/media/render-cuts.mjs +90 -0
  30. package/src/media/scene-detection.mjs +107 -0
  31. package/src/media/scene-review.mjs +205 -0
  32. package/src/project/assets.mjs +1194 -0
  33. package/src/project/create.mjs +100 -0
  34. package/src/project/presets.mjs +44 -0
  35. package/src/render/checkpoints.mjs +192 -0
  36. package/src/render/formats.mjs +163 -0
  37. package/src/render/hyperframes.mjs +590 -0
  38. package/src/render/process.mjs +111 -0
  39. package/src/render/progress.mjs +46 -0
  40. package/src/render/timeline-control.mjs +53 -0
  41. package/src/timeline/assemble.mjs +119 -0
  42. package/src/timeline/audio-effects.mjs +75 -0
  43. package/src/timeline/audio-mastering.mjs +236 -0
  44. package/src/timeline/audio-mix.mjs +105 -0
  45. package/src/timeline/audio-track-effects.mjs +139 -0
  46. package/src/timeline/audio-volume.mjs +63 -0
  47. package/src/timeline/background.mjs +8 -0
  48. package/src/timeline/blend-mode.mjs +34 -0
  49. package/src/timeline/caption-layouts.mjs +61 -0
  50. package/src/timeline/captions.mjs +80 -0
  51. package/src/timeline/edit.mjs +965 -0
  52. package/src/timeline/fingerprint.mjs +6 -0
  53. package/src/timeline/from-edl.mjs +31 -0
  54. package/src/timeline/history-store.mjs +281 -0
  55. package/src/timeline/history.mjs +69 -0
  56. package/src/timeline/inspect.mjs +117 -0
  57. package/src/timeline/keyframes.mjs +158 -0
  58. package/src/timeline/layers.mjs +18 -0
  59. package/src/timeline/mask.mjs +53 -0
  60. package/src/timeline/project.mjs +52 -0
  61. package/src/timeline/render.mjs +759 -0
  62. package/src/timeline/sidechain-render.mjs +209 -0
  63. package/src/timeline/source-fit.mjs +15 -0
  64. package/src/timeline/speech-bubble.mjs +26 -0
  65. package/src/timeline/speed-ramp.mjs +188 -0
  66. package/src/timeline/track-state.mjs +22 -0
  67. package/src/timeline/transition.mjs +34 -0
  68. package/src/timeline/validate.mjs +322 -0
  69. package/src/transcript/corrections.mjs +21 -0
  70. package/src/transcript/normalize.mjs +17 -0
  71. package/src/transcript/provider.mjs +42 -0
  72. package/src/transcript/subtitles.mjs +57 -0
  73. package/src/transcript/validate.mjs +39 -0
@@ -0,0 +1,1194 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { closeSync, copyFileSync, createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, constants as fsConstants } from 'node:fs';
4
+ import { copyFile, rename, rm } from 'node:fs/promises';
5
+ import { Transform } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { basename, dirname, extname, join, resolve } from 'node:path';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { normalizeVideoStreamMetadata, probeMedia, probeMediaAsync } from '../media/probe.mjs';
10
+ import { normalizeFrameRate } from '../media/frame-rate.mjs';
11
+ import { validateTimeline } from '../timeline/validate.mjs';
12
+ import { resetTimelineFileHistory } from '../timeline/history-store.mjs';
13
+ import { runProcess } from '../render/process.mjs';
14
+ import { createFfmpegProgressParser, emitRenderProgress } from '../render/progress.mjs';
15
+ import { linkOrCopyAsync, linkOrCopySync } from '../files.mjs';
16
+
17
+ const PROXY_PROFILE_VERSION = 2;
18
+ const PROXY_KEYFRAME_INTERVAL_SECONDS = 1;
19
+
20
+ const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
21
+
22
+ export function registerMediaAsset(projectPath, sourcePath, options = {}) {
23
+ const root = resolve(projectPath);
24
+ const source = resolve(sourcePath);
25
+ const metaPath = join(root, 'meta.json');
26
+ if (!existsSync(metaPath)) throw new Error(`Not an Engine project: ${root}`);
27
+ if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`Media file not found: ${source}`);
28
+
29
+ const extension = extname(source).toLowerCase();
30
+ const id = options.id ?? defaultAssetId(source);
31
+ if (!/^[a-z][a-z0-9-]*$/.test(id)) throw new TypeError('Asset id must be a lowercase kebab-case identifier.');
32
+ const manifest = readAssetManifest(root);
33
+ if (manifest.assets.some((asset) => asset.id === id)) throw new Error(`Asset already registered: ${id}`);
34
+
35
+ const image = IMAGE_EXTENSIONS.has(extension);
36
+ const probe = image ? probeImage(source) : probeMedia(source, { requireVideo: false });
37
+ const relativePath = `assets/${id}${extension}`;
38
+ const destination = join(root, relativePath);
39
+ if (existsSync(destination)) throw new Error(`Asset destination already exists: ${relativePath}`);
40
+ const stagedPath = join(root, 'assets', `.${id}.${randomUUID()}.register.tmp`);
41
+ const manifestPath = join(root, 'assets', 'manifest.json');
42
+ const originalManifest = existsSync(manifestPath) ? readFileSync(manifestPath) : undefined;
43
+ let copied = false;
44
+ let timelineUpdate;
45
+ try {
46
+ copyFileSync(source, stagedPath, fsConstants.COPYFILE_EXCL);
47
+ const probe = image ? probeImage(stagedPath) : probeMedia(stagedPath, { requireVideo: false });
48
+ linkOrCopySync(stagedPath, destination);
49
+ copied = true;
50
+ const asset = {
51
+ id,
52
+ path: relativePath,
53
+ type: image ? 'image' : probe.width === null ? 'audio' : 'video',
54
+ sizeBytes: statSync(stagedPath).size,
55
+ sha256: hashFile(stagedPath),
56
+ ...(image ? { width: probe.width, height: probe.height, codec: probe.codec } : {
57
+ durationSeconds: probe.durationSeconds,
58
+ width: probe.width,
59
+ height: probe.height,
60
+ frameRate: probe.frameRate,
61
+ nominalFrameRate: probe.nominalFrameRate,
62
+ hasAudio: probe.hasAudio,
63
+ formatName: probe.formatName,
64
+ videoStreams: probe.videoStreams,
65
+ audioStreams: probe.audioStreams,
66
+ videoCodec: probe.videoCodec,
67
+ audioCodec: probe.audioCodec,
68
+ streamTiming: probe.streamTiming,
69
+ }),
70
+ };
71
+ timelineUpdate = prepareTimelineAssetUpdate(root, asset);
72
+ const latestManifest = readAssetManifest(root);
73
+ if (latestManifest.assets.some((entry) => entry.id === id)) throw new Error(`Asset already registered: ${id}`);
74
+ writeAssetManifest(root, { ...latestManifest, assets: [...latestManifest.assets, asset] });
75
+ if (timelineUpdate.next) writeFileAtomically(timelineUpdate.path, `${JSON.stringify(timelineUpdate.next, null, 2)}\n`);
76
+ checkpointTimelineHistory(timelineUpdate);
77
+ } catch (error) {
78
+ if (copied) {
79
+ if (timelineUpdate) rollbackAssetRegistration(destination, manifestPath, originalManifest, timelineUpdate, error);
80
+ else rmSync(destination, { force: true });
81
+ }
82
+ throw error;
83
+ } finally {
84
+ rmSync(stagedPath, { force: true });
85
+ }
86
+ return readAssetManifest(root).assets.find((asset) => asset.id === id);
87
+ }
88
+
89
+ export async function registerMediaAssetAsync(projectPath, sourcePath, options = {}) {
90
+ const { signal, onProgress, ...registerOptions } = options;
91
+ if (signal !== undefined && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function')) {
92
+ throw new TypeError('Asset registration signal must be an AbortSignal.');
93
+ }
94
+ if (onProgress !== undefined && typeof onProgress !== 'function') throw new TypeError('Asset registration onProgress must be a function.');
95
+ for (const field of Object.keys(registerOptions)) if (field !== 'id') throw new TypeError(`Unsupported asset registration option: ${field}.`);
96
+ if (signal?.aborted) throw signal.reason ?? new DOMException('Asset registration was aborted.', 'AbortError');
97
+ const root = resolve(projectPath);
98
+ const source = resolve(sourcePath);
99
+ const metaPath = join(root, 'meta.json');
100
+ if (!existsSync(metaPath)) throw new Error(`Not an Engine project: ${root}`);
101
+ if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`Media file not found: ${source}`);
102
+ const extension = extname(source).toLowerCase();
103
+ const id = registerOptions.id ?? defaultAssetId(source);
104
+ if (!/^[a-z][a-z0-9-]*$/.test(id)) throw new TypeError('Asset id must be a lowercase kebab-case identifier.');
105
+ const manifest = readAssetManifest(root);
106
+ if (manifest.assets.some((asset) => asset.id === id)) throw new Error(`Asset already registered: ${id}`);
107
+ const image = IMAGE_EXTENSIONS.has(extension);
108
+ const relativePath = `assets/${id}${extension}`;
109
+ const destination = join(root, relativePath);
110
+ if (existsSync(destination)) throw new Error(`Asset destination already exists: ${relativePath}`);
111
+ const assetDirectory = join(root, 'assets');
112
+ const stagedPath = join(assetDirectory, `.${id}.${randomUUID()}.register.tmp`);
113
+ let destinationLinked = false;
114
+ let registered = false;
115
+ let activeStage = 'asset-copy';
116
+ try {
117
+ const sourceBytes = statSync(source).size;
118
+ emitRenderProgress(onProgress, 'asset-copy', 'started', { assetId: id, total: sourceBytes });
119
+ let copiedBytes = 0;
120
+ const sha256 = await copyFileAndHash(source, stagedPath, signal, (completed) => {
121
+ copiedBytes = completed;
122
+ emitRenderProgress(onProgress, 'asset-copy', 'progress', {
123
+ assetId: id,
124
+ percent: sourceBytes > 0 ? Math.min(99, completed / sourceBytes * 100) : 0,
125
+ completed,
126
+ total: sourceBytes,
127
+ });
128
+ });
129
+ emitRenderProgress(onProgress, 'asset-copy', 'completed', { assetId: id, completed: copiedBytes, total: sourceBytes });
130
+ activeStage = 'asset-probe';
131
+ emitRenderProgress(onProgress, activeStage, 'started', { assetId: id });
132
+ const probe = image ? await probeImageAsync(stagedPath, signal) : await probeMediaAsync(stagedPath, { requireVideo: false, signal });
133
+ signal?.throwIfAborted();
134
+ emitRenderProgress(onProgress, activeStage, 'completed', { assetId: id });
135
+ const asset = {
136
+ id,
137
+ path: relativePath,
138
+ type: image ? 'image' : probe.width === null ? 'audio' : 'video',
139
+ sizeBytes: statSync(stagedPath).size,
140
+ sha256,
141
+ ...(image ? { width: probe.width, height: probe.height, codec: probe.codec } : {
142
+ durationSeconds: probe.durationSeconds,
143
+ width: probe.width,
144
+ height: probe.height,
145
+ frameRate: probe.frameRate,
146
+ nominalFrameRate: probe.nominalFrameRate,
147
+ hasAudio: probe.hasAudio,
148
+ formatName: probe.formatName,
149
+ videoStreams: probe.videoStreams,
150
+ audioStreams: probe.audioStreams,
151
+ videoCodec: probe.videoCodec,
152
+ audioCodec: probe.audioCodec,
153
+ streamTiming: probe.streamTiming,
154
+ }),
155
+ };
156
+ activeStage = 'asset-publish';
157
+ emitRenderProgress(onProgress, activeStage, 'started', { assetId: id });
158
+ await linkOrCopyAsync(stagedPath, destination, {
159
+ signal,
160
+ onCopyProgress: (completed, total) => emitRenderProgress(onProgress, activeStage, 'progress', {
161
+ assetId: id,
162
+ completed,
163
+ total,
164
+ percent: total > 0 ? Math.min(99, completed / total * 100) : 0,
165
+ }),
166
+ });
167
+ destinationLinked = true;
168
+ const latestManifest = readAssetManifest(root);
169
+ if (latestManifest.assets.some((entry) => entry.id === id)) throw new Error(`Asset already registered: ${id}`);
170
+ const manifestPath = join(root, 'assets', 'manifest.json');
171
+ const originalManifest = existsSync(manifestPath) ? readFileSync(manifestPath) : undefined;
172
+ const timelineUpdate = prepareTimelineAssetUpdate(root, asset);
173
+ try {
174
+ writeAssetManifest(root, { ...latestManifest, assets: [...latestManifest.assets, asset] });
175
+ if (timelineUpdate.next) writeFileAtomically(timelineUpdate.path, `${JSON.stringify(timelineUpdate.next, null, 2)}\n`);
176
+ checkpointTimelineHistory(timelineUpdate);
177
+ } catch (error) {
178
+ rollbackAssetRegistration(destination, manifestPath, originalManifest, timelineUpdate, error);
179
+ throw error;
180
+ }
181
+ registered = true;
182
+ emitRenderProgress(onProgress, activeStage, 'completed', { assetId: id });
183
+ return asset;
184
+ } catch (error) {
185
+ emitRenderProgress(onProgress, activeStage, 'failed', { assetId: id });
186
+ if (destinationLinked && !registered && existsSync(destination)) rmSync(destination, { force: true });
187
+ throw error;
188
+ } finally {
189
+ rmSync(stagedPath, { force: true });
190
+ }
191
+ }
192
+
193
+ /** Register multiple media sources sequentially and return per-source outcomes. */
194
+ export async function registerMediaAssetsAsync(projectPath, sources, options = {}) {
195
+ const { signal, onProgress } = options;
196
+ if (signal !== undefined && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function')) {
197
+ throw new TypeError('Asset registration signal must be an AbortSignal.');
198
+ }
199
+ if (onProgress !== undefined && typeof onProgress !== 'function') throw new TypeError('Asset registration onProgress must be a function.');
200
+ for (const field of Object.keys(options)) if (!['signal', 'onProgress'].includes(field)) throw new TypeError(`Unsupported batch asset registration option: ${field}.`);
201
+ if (!Array.isArray(sources) || sources.length === 0) throw new TypeError('Asset registration sources must be a non-empty array.');
202
+ signal?.throwIfAborted();
203
+
204
+ const root = resolve(projectPath);
205
+ if (!existsSync(join(root, 'meta.json'))) throw new Error(`Not an Engine project: ${root}`);
206
+ const entries = sources.map((entry) => {
207
+ if (typeof entry !== 'string' && (!entry || typeof entry !== 'object' || Array.isArray(entry))) {
208
+ throw new TypeError('Each batch source must be a path string or an object with path and optional id.');
209
+ }
210
+ if (entry && typeof entry === 'object') {
211
+ for (const field of Object.keys(entry)) if (!['path', 'id'].includes(field)) throw new TypeError(`Unsupported batch source field: ${field}.`);
212
+ if (typeof entry.path !== 'string' || !entry.path.trim()) throw new TypeError('Each batch source object requires a non-empty path.');
213
+ }
214
+ const sourcePath = resolve(typeof entry === 'string' ? entry : entry.path ?? '');
215
+ const id = typeof entry === 'string' ? defaultAssetId(sourcePath) : entry.id ?? defaultAssetId(sourcePath);
216
+ if (!/^[a-z][a-z0-9-]*$/.test(id)) throw new TypeError(`Asset id must be a lowercase kebab-case identifier: ${id}`);
217
+ return { sourcePath, id };
218
+ });
219
+ const ids = new Set();
220
+ for (const { id } of entries) {
221
+ if (ids.has(id)) throw new Error(`Batch asset IDs must be unique: ${id}`);
222
+ ids.add(id);
223
+ }
224
+ const existingAssets = readAssetManifest(root).assets;
225
+ const existingIds = new Set(existingAssets.map((asset) => asset.id));
226
+ for (const { id, sourcePath } of entries) {
227
+ if (existingIds.has(id)) throw new Error(`Asset already registered: ${id}`);
228
+ const destination = join(root, 'assets', `${id}${extname(sourcePath).toLowerCase()}`);
229
+ if (existsSync(destination)) throw new Error(`Asset destination already exists: assets/${id}${extname(sourcePath).toLowerCase()}`);
230
+ }
231
+
232
+ emitRenderProgress(onProgress, 'asset-batch', 'started', { total: entries.length });
233
+ const registered = [];
234
+ const failed = [];
235
+ for (const [index, entry] of entries.entries()) {
236
+ if (signal?.aborted) {
237
+ emitRenderProgress(onProgress, 'asset-batch', 'failed', { completed: registered.length, total: entries.length });
238
+ signal.throwIfAborted();
239
+ }
240
+ const batchDetails = { assetId: entry.id, batchIndex: index + 1, batchTotal: entries.length };
241
+ emitRenderProgress(onProgress, 'asset-batch-item', 'started', batchDetails);
242
+ try {
243
+ const asset = await registerMediaAssetAsync(root, entry.sourcePath, {
244
+ id: entry.id,
245
+ signal,
246
+ onProgress(event) {
247
+ emitRenderProgress(onProgress, event.stage, event.phase, { ...event, ...batchDetails });
248
+ },
249
+ });
250
+ registered.push(asset);
251
+ emitRenderProgress(onProgress, 'asset-batch-item', 'completed', batchDetails);
252
+ } catch (error) {
253
+ if (signal?.aborted || error instanceof AggregateError) {
254
+ emitRenderProgress(onProgress, 'asset-batch', 'failed', { completed: registered.length, total: entries.length });
255
+ throw error;
256
+ }
257
+ failed.push({ ...batchDetails, path: entry.sourcePath, message: error.message });
258
+ emitRenderProgress(onProgress, 'asset-batch-item', 'failed', { ...batchDetails, message: error.message });
259
+ }
260
+ emitRenderProgress(onProgress, 'asset-batch', 'progress', {
261
+ ...batchDetails,
262
+ completed: index + 1,
263
+ total: entries.length,
264
+ percent: (index + 1) / entries.length * 100,
265
+ });
266
+ }
267
+ emitRenderProgress(onProgress, 'asset-batch', 'completed', {
268
+ completed: entries.length,
269
+ total: entries.length,
270
+ registered: registered.length,
271
+ ...(failed.length ? { failed: failed.length } : {}),
272
+ });
273
+ return { registered, failed };
274
+ }
275
+
276
+ /** Read the registered project media catalog without touching its files. */
277
+ export function listProjectAssets(projectPath) {
278
+ const root = resolve(projectPath);
279
+ const manifest = structuredClone(readAssetManifest(root));
280
+ return {
281
+ ...manifest,
282
+ assets: manifest.assets.map((asset) => ({
283
+ ...asset,
284
+ ...(asset.videoStreams === undefined ? {} : { videoStreams: normalizeVideoStreamMetadata(asset.videoStreams) }),
285
+ })),
286
+ };
287
+ }
288
+
289
+ /** Remove an unused asset from project catalogs without deleting its media files. */
290
+ export function unregisterProjectAsset(projectPath, assetId) {
291
+ const root = resolve(projectPath);
292
+ if (typeof assetId !== 'string' || !/^[a-z][a-z0-9-]*$/.test(assetId)) throw new TypeError('Asset id must be a lowercase kebab-case identifier.');
293
+ const manifest = readAssetManifest(root);
294
+ const manifestPath = join(root, 'assets', 'manifest.json');
295
+ const originalManifest = existsSync(manifestPath) ? readFileSync(manifestPath) : undefined;
296
+ const index = manifest.assets.findIndex((asset) => asset.id === assetId);
297
+ if (index < 0) throw new Error(`Asset is not registered: ${assetId}`);
298
+
299
+ const timelinePath = join(root, 'timeline.json');
300
+ if (existsSync(timelinePath) && lstatSync(timelinePath).isSymbolicLink()) throw new TypeError('Project timeline cannot be a symbolic link.');
301
+ const originalTimeline = existsSync(timelinePath) ? readFileSync(timelinePath) : undefined;
302
+ const originalHistory = readTimelineHistorySnapshot(timelinePath);
303
+ let nextTimeline;
304
+ let timelineChanged = false;
305
+ if (originalTimeline) {
306
+ nextTimeline = JSON.parse(originalTimeline.toString('utf8'));
307
+ validateTimeline(nextTimeline);
308
+ const references = nextTimeline.tracks.flatMap((track) => (track.clips ?? [])
309
+ .filter((clip) => clip.assetId === assetId)
310
+ .map((clip) => `${track.id}/${clip.id}`));
311
+ if (references.length > 0) throw new Error(`Asset ${assetId} is still used by timeline clips: ${references.join(', ')}.`);
312
+ if (nextTimeline.lineage?.assetId === assetId) throw new Error(`Asset ${assetId} is retained as timeline source lineage and cannot be unregistered.`);
313
+ const assets = nextTimeline.assets.filter((asset) => asset.id !== assetId);
314
+ timelineChanged = assets.length !== nextTimeline.assets.length;
315
+ if (timelineChanged) nextTimeline = validateTimeline({ ...nextTimeline, assets });
316
+ }
317
+
318
+ const updatedManifest = { ...manifest, assets: manifest.assets.filter((_, assetIndex) => assetIndex !== index) };
319
+ try {
320
+ writeAssetManifest(root, updatedManifest);
321
+ if (timelineChanged) writeFileAtomically(timelinePath, `${JSON.stringify(nextTimeline, null, 2)}\n`);
322
+ if (timelineChanged) checkpointTimelineHistory({ path: timelinePath, originalHistory });
323
+ } catch (error) {
324
+ const rollbackErrors = [];
325
+ try { restoreFile(manifestPath, originalManifest); } catch (rollbackError) { rollbackErrors.push(rollbackError); }
326
+ if (originalTimeline !== undefined) {
327
+ try { writeFileAtomically(timelinePath, originalTimeline); } catch (rollbackError) { rollbackErrors.push(rollbackError); }
328
+ }
329
+ try { restoreFile(getTimelineHistoryPath(timelinePath), originalHistory); } catch (rollbackError) { rollbackErrors.push(rollbackError); }
330
+ if (rollbackErrors.length > 0) {
331
+ throw new AggregateError([error, ...rollbackErrors], `Asset unregistration failed and rollback was incomplete. Preserve project files and inspect ${root}.`);
332
+ }
333
+ throw error;
334
+ }
335
+ return { asset: structuredClone(manifest.assets[index]), mediaFilesPreserved: true };
336
+ }
337
+
338
+ export function verifyProjectAssets(projectPath) {
339
+ const root = resolve(projectPath);
340
+ const manifest = readAssetManifest(root);
341
+ return manifest.assets.flatMap((asset) => {
342
+ const path = resolveProjectAsset(root, asset.path);
343
+ const sourceStatus = (status, actualSha256) => ({
344
+ id: asset.id,
345
+ variant: 'source',
346
+ path: asset.path,
347
+ status,
348
+ expectedSha256: asset.sha256,
349
+ ...(actualSha256 ? { actualSha256 } : {}),
350
+ });
351
+ if (!existsSync(path) || !statSync(path).isFile()) {
352
+ return [sourceStatus('missing')];
353
+ }
354
+ if (lstatSync(path).isSymbolicLink()) return [sourceStatus('modified')];
355
+ const actualSha256 = hashFile(path);
356
+ const source = sourceStatus(actualSha256 === asset.sha256 ? 'ok' : 'modified', actualSha256);
357
+ const proxies = (asset.proxies ?? []).map((proxy) => {
358
+ const proxyPath = resolveProjectProxy(root, proxy.path);
359
+ if (!existsSync(proxyPath) || !statSync(proxyPath).isFile() || lstatSync(proxyPath).isSymbolicLink()) {
360
+ return { id: asset.id, variant: 'proxy', path: proxy.path, status: 'missing', expectedSha256: proxy.sha256 };
361
+ }
362
+ const actualProxySha256 = hashFile(proxyPath);
363
+ return {
364
+ id: asset.id,
365
+ variant: 'proxy',
366
+ path: proxy.path,
367
+ status: actualProxySha256 === proxy.sha256 && proxy.sourceSha256 === asset.sha256 ? 'ok' : 'modified',
368
+ expectedSha256: proxy.sha256,
369
+ actualSha256: actualProxySha256,
370
+ };
371
+ });
372
+ return [source, ...proxies];
373
+ });
374
+ }
375
+
376
+ export async function verifyProjectAssetsAsync(projectPath, { signal, onProgress } = {}) {
377
+ if (signal !== undefined && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function')) {
378
+ throw new TypeError('Asset verification signal must be an AbortSignal.');
379
+ }
380
+ if (onProgress !== undefined && typeof onProgress !== 'function') throw new TypeError('Asset verification onProgress must be a function.');
381
+ signal?.throwIfAborted();
382
+ const root = resolve(projectPath);
383
+ const manifest = readAssetManifest(root);
384
+ const filePaths = manifest.assets.flatMap((asset) => [
385
+ resolveProjectAsset(root, asset.path),
386
+ ...(asset.proxies ?? []).map((proxy) => resolveProjectProxy(root, proxy.path)),
387
+ ]);
388
+ const totalBytes = filePaths.reduce((total, path) => {
389
+ try {
390
+ const file = lstatSync(path);
391
+ return file.isFile() && !file.isSymbolicLink() ? total + file.size : total;
392
+ } catch {
393
+ return total;
394
+ }
395
+ }, 0);
396
+ let completedBytes = 0;
397
+ const results = [];
398
+ emitRenderProgress(onProgress, 'asset-verification', 'started', { total: totalBytes });
399
+ const onFileBytes = (assetId, variant) => (bytes) => {
400
+ completedBytes += bytes;
401
+ emitRenderProgress(onProgress, 'asset-verification', 'progress', {
402
+ percent: totalBytes > 0 ? Math.min(99, completedBytes / totalBytes * 100) : 0,
403
+ completed: completedBytes,
404
+ total: totalBytes,
405
+ assetId,
406
+ variant,
407
+ });
408
+ };
409
+ try {
410
+ for (const asset of manifest.assets) {
411
+ signal?.throwIfAborted();
412
+ const path = resolveProjectAsset(root, asset.path);
413
+ const sourceStatus = (status, actualSha256) => ({
414
+ id: asset.id,
415
+ variant: 'source',
416
+ path: asset.path,
417
+ status,
418
+ expectedSha256: asset.sha256,
419
+ ...(actualSha256 ? { actualSha256 } : {}),
420
+ });
421
+ if (!existsSync(path) || !statSync(path).isFile()) {
422
+ results.push(sourceStatus('missing'));
423
+ continue;
424
+ }
425
+ if (lstatSync(path).isSymbolicLink()) {
426
+ results.push(sourceStatus('modified'));
427
+ continue;
428
+ }
429
+ const actualSha256 = await hashFileAsync(path, signal, onFileBytes(asset.id, 'source'));
430
+ results.push(sourceStatus(actualSha256 === asset.sha256 ? 'ok' : 'modified', actualSha256));
431
+ for (const proxy of asset.proxies ?? []) {
432
+ signal?.throwIfAborted();
433
+ const proxyPath = resolveProjectProxy(root, proxy.path);
434
+ if (!existsSync(proxyPath) || !statSync(proxyPath).isFile() || lstatSync(proxyPath).isSymbolicLink()) {
435
+ results.push({ id: asset.id, variant: 'proxy', path: proxy.path, status: 'missing', expectedSha256: proxy.sha256 });
436
+ continue;
437
+ }
438
+ const actualProxySha256 = await hashFileAsync(proxyPath, signal, onFileBytes(asset.id, 'proxy'));
439
+ results.push({
440
+ id: asset.id,
441
+ variant: 'proxy',
442
+ path: proxy.path,
443
+ status: actualProxySha256 === proxy.sha256 && proxy.sourceSha256 === asset.sha256 ? 'ok' : 'modified',
444
+ expectedSha256: proxy.sha256,
445
+ actualSha256: actualProxySha256,
446
+ });
447
+ }
448
+ }
449
+ } catch (error) {
450
+ emitRenderProgress(onProgress, 'asset-verification', 'failed', { completed: completedBytes, total: totalBytes });
451
+ throw error;
452
+ }
453
+ emitRenderProgress(onProgress, 'asset-verification', 'completed', { completed: completedBytes, total: totalBytes });
454
+ return results;
455
+ }
456
+
457
+ export function relinkProjectAsset(projectPath, assetId, sourcePath) {
458
+ const root = resolve(projectPath);
459
+ const manifest = readAssetManifest(root);
460
+ const index = manifest.assets.findIndex((asset) => asset.id === assetId);
461
+ if (index < 0) throw new Error(`Asset is not registered: ${assetId}`);
462
+ const source = resolve(sourcePath);
463
+ if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`Replacement media file not found: ${source}`);
464
+
465
+ const previous = manifest.assets[index];
466
+ if (extname(source).toLowerCase() !== extname(previous.path).toLowerCase()) throw new TypeError(`Replacement media must keep the registered ${extname(previous.path)} file extension.`);
467
+ const image = IMAGE_EXTENSIONS.has(extname(source).toLowerCase());
468
+ if ((previous.type === 'image') !== image) throw new TypeError(`Replacement media must be a ${previous.type} asset.`);
469
+ const probe = image ? probeImage(source) : probeMedia(source, { requireVideo: false });
470
+ const type = image ? 'image' : probe.width === null ? 'audio' : 'video';
471
+ if (type !== previous.type) throw new TypeError(`Replacement media must be a ${previous.type} asset.`);
472
+
473
+ const destination = resolveProjectAsset(root, previous.path);
474
+ if (existsSync(destination) && lstatSync(destination).isSymbolicLink()) throw new TypeError('Registered asset paths cannot be symbolic links.');
475
+ const assetDirectory = dirname(destination);
476
+ mkdirSync(assetDirectory, { recursive: true });
477
+ const stagedPath = join(assetDirectory, `.${assetId}.${randomUUID()}.relink.tmp`);
478
+ const backupPath = join(assetDirectory, `.${assetId}.${randomUUID()}.backup.tmp`);
479
+ const manifestPath = join(root, 'assets', 'manifest.json');
480
+ const oldAssetExists = existsSync(destination);
481
+ const originalManifest = readFileSync(manifestPath);
482
+ const timelinePath = join(root, 'timeline.json');
483
+ if (existsSync(timelinePath) && lstatSync(timelinePath).isSymbolicLink()) throw new TypeError('Project timeline cannot be a symbolic link.');
484
+ const originalTimeline = existsSync(timelinePath) ? readFileSync(timelinePath) : undefined;
485
+ const originalHistory = readTimelineHistorySnapshot(timelinePath);
486
+
487
+ const replacementSha256 = hashFile(source);
488
+ const updated = {
489
+ ...previous,
490
+ ...(image ? { width: probe.width, height: probe.height, codec: probe.codec } : {
491
+ durationSeconds: probe.durationSeconds,
492
+ width: probe.width,
493
+ height: probe.height,
494
+ frameRate: probe.frameRate,
495
+ nominalFrameRate: probe.nominalFrameRate,
496
+ hasAudio: probe.hasAudio,
497
+ formatName: probe.formatName,
498
+ videoStreams: probe.videoStreams,
499
+ audioStreams: probe.audioStreams,
500
+ videoCodec: probe.videoCodec,
501
+ audioCodec: probe.audioCodec,
502
+ streamTiming: probe.streamTiming,
503
+ }),
504
+ sizeBytes: statSync(source).size,
505
+ sha256: replacementSha256,
506
+ proxies: (previous.proxies ?? []).filter((proxy) => proxy.sourceSha256 === replacementSha256),
507
+ };
508
+ const nextManifest = { ...manifest, assets: manifest.assets.map((asset, assetIndex) => assetIndex === index ? updated : asset) };
509
+ let nextTimeline;
510
+ let timelineChanged = false;
511
+ if (originalTimeline) {
512
+ nextTimeline = JSON.parse(originalTimeline.toString('utf8'));
513
+ const timelineAssetIndex = nextTimeline.assets.findIndex((asset) => asset.id === assetId);
514
+ if (timelineAssetIndex >= 0) {
515
+ nextTimeline.assets[timelineAssetIndex] = { ...nextTimeline.assets[timelineAssetIndex], ...updated };
516
+ if (nextTimeline.lineage?.assetId === assetId) nextTimeline.lineage.sourceSha256 = updated.sha256;
517
+ validateTimeline(nextTimeline);
518
+ timelineChanged = true;
519
+ }
520
+ }
521
+ let installedNewAsset = false;
522
+ let manifestCommitted = false;
523
+ try {
524
+ copyFileSync(source, stagedPath, fsConstants.COPYFILE_EXCL);
525
+ if (oldAssetExists) copyFileSync(destination, backupPath, fsConstants.COPYFILE_EXCL);
526
+ installedNewAsset = true;
527
+ copyFileSync(stagedPath, destination);
528
+ writeAssetManifest(root, nextManifest);
529
+ if (nextTimeline) writeFileAtomically(timelinePath, `${JSON.stringify(nextTimeline, null, 2)}\n`);
530
+ if (timelineChanged) checkpointTimelineHistory({ path: timelinePath, originalHistory });
531
+ manifestCommitted = true;
532
+ } catch (error) {
533
+ if (!manifestCommitted) {
534
+ if (installedNewAsset) {
535
+ if (existsSync(backupPath)) copyFileSync(backupPath, destination);
536
+ else rmSync(destination, { force: true });
537
+ }
538
+ writeFileAtomically(manifestPath, originalManifest);
539
+ if (originalTimeline) writeFileAtomically(timelinePath, originalTimeline);
540
+ restoreFile(getTimelineHistoryPath(timelinePath), originalHistory);
541
+ }
542
+ throw new Error(`Could not relink asset ${assetId}: ${error.message}`);
543
+ } finally {
544
+ rmSync(stagedPath, { force: true });
545
+ rmSync(backupPath, { force: true });
546
+ }
547
+ for (const proxy of previous.proxies ?? []) {
548
+ if (proxy.sourceSha256 !== updated.sha256) rmSync(resolveProjectProxy(root, proxy.path), { force: true });
549
+ }
550
+ return updated;
551
+ }
552
+
553
+ export async function relinkProjectAssetAsync(projectPath, assetId, sourcePath, options = {}) {
554
+ const { signal, onProgress } = options;
555
+ if (Object.keys(options).some((field) => !['signal', 'onProgress'].includes(field))) throw new TypeError('Unsupported asset relink option.');
556
+ if (signal !== undefined && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function')) {
557
+ throw new TypeError('Asset relink signal must be an AbortSignal.');
558
+ }
559
+ if (onProgress !== undefined && typeof onProgress !== 'function') throw new TypeError('Asset relink onProgress must be a function.');
560
+ if (signal?.aborted) throw signal.reason ?? new DOMException('Asset relinking was aborted.', 'AbortError');
561
+ const root = resolve(projectPath);
562
+ const manifest = readAssetManifest(root);
563
+ const index = manifest.assets.findIndex((asset) => asset.id === assetId);
564
+ if (index < 0) throw new Error(`Asset is not registered: ${assetId}`);
565
+ const source = resolve(sourcePath);
566
+ if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`Replacement media file not found: ${source}`);
567
+ const previous = manifest.assets[index];
568
+ if (extname(source).toLowerCase() !== extname(previous.path).toLowerCase()) throw new TypeError(`Replacement media must keep the registered ${extname(previous.path)} file extension.`);
569
+ const image = IMAGE_EXTENSIONS.has(extname(source).toLowerCase());
570
+ if ((previous.type === 'image') !== image) throw new TypeError(`Replacement media must be a ${previous.type} asset.`);
571
+
572
+ const destination = resolveProjectAsset(root, previous.path);
573
+ if (existsSync(destination) && lstatSync(destination).isSymbolicLink()) throw new TypeError('Registered asset paths cannot be symbolic links.');
574
+ const assetDirectory = dirname(destination);
575
+ mkdirSync(assetDirectory, { recursive: true });
576
+ const stagedPath = join(assetDirectory, `.${assetId}.${randomUUID()}.relink.tmp`);
577
+ const backupPath = join(assetDirectory, `.${assetId}.${randomUUID()}.backup.tmp`);
578
+ const manifestPath = join(root, 'assets', 'manifest.json');
579
+ const timelinePath = join(root, 'timeline.json');
580
+ const oldAssetExists = existsSync(destination);
581
+ if (existsSync(timelinePath) && lstatSync(timelinePath).isSymbolicLink()) throw new TypeError('Project timeline cannot be a symbolic link.');
582
+ const originalManifest = readFileSync(manifestPath);
583
+ const originalTimeline = existsSync(timelinePath) ? readFileSync(timelinePath) : undefined;
584
+ const originalHistory = readTimelineHistorySnapshot(timelinePath);
585
+ let movedOldAsset = false;
586
+ let installedNewAsset = false;
587
+ let commitStarted = false;
588
+ let committed = false;
589
+ let activeStage = 'asset-relink-copy';
590
+ try {
591
+ const sourceBytes = statSync(source).size;
592
+ emitRenderProgress(onProgress, activeStage, 'started', { assetId, total: sourceBytes });
593
+ let copiedBytes = 0;
594
+ const replacementSha256 = await copyFileAndHash(source, stagedPath, signal, (completed) => {
595
+ copiedBytes = completed;
596
+ emitRenderProgress(onProgress, activeStage, 'progress', {
597
+ assetId,
598
+ percent: sourceBytes > 0 ? Math.min(99, completed / sourceBytes * 100) : 0,
599
+ completed,
600
+ total: sourceBytes,
601
+ });
602
+ });
603
+ emitRenderProgress(onProgress, activeStage, 'completed', { assetId, completed: copiedBytes, total: sourceBytes });
604
+ activeStage = 'asset-relink-probe';
605
+ emitRenderProgress(onProgress, activeStage, 'started', { assetId });
606
+ const probe = image ? await probeImageAsync(stagedPath, signal) : await probeMediaAsync(stagedPath, { requireVideo: false, signal });
607
+ emitRenderProgress(onProgress, activeStage, 'completed', { assetId });
608
+ const type = image ? 'image' : probe.width === null ? 'audio' : 'video';
609
+ if (type !== previous.type) throw new TypeError(`Replacement media must be a ${previous.type} asset.`);
610
+ const updated = {
611
+ ...previous,
612
+ ...(image ? { width: probe.width, height: probe.height, codec: probe.codec } : {
613
+ durationSeconds: probe.durationSeconds,
614
+ width: probe.width,
615
+ height: probe.height,
616
+ frameRate: probe.frameRate,
617
+ nominalFrameRate: probe.nominalFrameRate,
618
+ hasAudio: probe.hasAudio,
619
+ formatName: probe.formatName,
620
+ videoStreams: probe.videoStreams,
621
+ audioStreams: probe.audioStreams,
622
+ videoCodec: probe.videoCodec,
623
+ audioCodec: probe.audioCodec,
624
+ streamTiming: probe.streamTiming,
625
+ }),
626
+ sizeBytes: statSync(stagedPath).size,
627
+ sha256: replacementSha256,
628
+ proxies: (previous.proxies ?? []).filter((proxy) => proxy.sourceSha256 === replacementSha256),
629
+ };
630
+ const nextManifest = { ...manifest, assets: manifest.assets.map((asset, assetIndex) => assetIndex === index ? updated : asset) };
631
+ let nextTimeline;
632
+ let timelineChanged = false;
633
+ if (originalTimeline) {
634
+ nextTimeline = JSON.parse(originalTimeline.toString('utf8'));
635
+ const timelineAssetIndex = nextTimeline.assets.findIndex((asset) => asset.id === assetId);
636
+ if (timelineAssetIndex >= 0) {
637
+ nextTimeline.assets[timelineAssetIndex] = { ...nextTimeline.assets[timelineAssetIndex], ...updated };
638
+ if (nextTimeline.lineage?.assetId === assetId) nextTimeline.lineage.sourceSha256 = updated.sha256;
639
+ validateTimeline(nextTimeline);
640
+ timelineChanged = true;
641
+ }
642
+ }
643
+ signal?.throwIfAborted();
644
+ activeStage = 'asset-relink-publish';
645
+ emitRenderProgress(onProgress, activeStage, 'started', { assetId });
646
+ commitStarted = true;
647
+ if (oldAssetExists) {
648
+ await rename(destination, backupPath);
649
+ movedOldAsset = true;
650
+ }
651
+ await rename(stagedPath, destination);
652
+ installedNewAsset = true;
653
+ writeAssetManifest(root, nextManifest);
654
+ if (nextTimeline) writeFileAtomically(timelinePath, `${JSON.stringify(nextTimeline, null, 2)}\n`);
655
+ if (timelineChanged) checkpointTimelineHistory({ path: timelinePath, originalHistory });
656
+ committed = true;
657
+ emitRenderProgress(onProgress, activeStage, 'completed', { assetId });
658
+ for (const proxy of previous.proxies ?? []) {
659
+ if (proxy.sourceSha256 !== updated.sha256) rmSync(resolveProjectProxy(root, proxy.path), { force: true });
660
+ }
661
+ return updated;
662
+ } catch (error) {
663
+ emitRenderProgress(onProgress, activeStage, 'failed', { assetId });
664
+ if (commitStarted && !committed) {
665
+ if (installedNewAsset) await unlink(destination).catch(() => {});
666
+ if (movedOldAsset && !existsSync(destination)) await rename(backupPath, destination).catch(() => {});
667
+ writeFileAtomically(manifestPath, originalManifest);
668
+ if (originalTimeline) writeFileAtomically(timelinePath, originalTimeline);
669
+ restoreFile(getTimelineHistoryPath(timelinePath), originalHistory);
670
+ }
671
+ const retainedBackup = movedOldAsset && existsSync(backupPath) ? ` Previous media backup retained at ${backupPath}.` : '';
672
+ throw new Error(`Could not relink asset ${assetId}: ${error.message}${retainedBackup}`, { cause: error });
673
+ } finally {
674
+ rmSync(stagedPath, { force: true });
675
+ if (committed || !movedOldAsset) rmSync(backupPath, { force: true });
676
+ }
677
+ }
678
+
679
+ export function createAssetProxy(projectPath, assetId, options = {}) {
680
+ const context = prepareAssetProxy(projectPath, assetId, options);
681
+ if (context.cached) return context.cached;
682
+ try {
683
+ const result = spawnSync('ffmpeg', context.args, { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
684
+ if (result.error || result.status !== 0) throw new Error(`Could not generate video proxy: ${result.error?.message ?? result.stderr.trim()}`);
685
+ return storeAssetProxy(context, probeMedia(context.stagedPath));
686
+ } catch (error) {
687
+ rollbackAssetProxy(context);
688
+ throw error;
689
+ } finally {
690
+ cleanupAssetProxy(context);
691
+ }
692
+ }
693
+
694
+ export async function createAssetProxyAsync(projectPath, assetId, options = {}) {
695
+ const { signal, onProgress, ...proxyOptions } = options;
696
+ if (signal !== undefined && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function')) {
697
+ throw new TypeError('Proxy signal must be an AbortSignal.');
698
+ }
699
+ if (onProgress !== undefined && typeof onProgress !== 'function') throw new TypeError('Proxy onProgress must be a function.');
700
+ if (signal?.aborted) throw signal.reason ?? new DOMException('Proxy generation was aborted.', 'AbortError');
701
+ emitRenderProgress(onProgress, 'proxy-preflight', 'started');
702
+ let context;
703
+ try {
704
+ context = await prepareAssetProxyAsync(projectPath, assetId, proxyOptions, signal);
705
+ } catch (error) {
706
+ emitRenderProgress(onProgress, 'proxy-preflight', 'failed', { assetId });
707
+ throw error;
708
+ }
709
+ emitRenderProgress(onProgress, 'proxy-preflight', 'completed');
710
+ if (context.cached) {
711
+ signal?.throwIfAborted();
712
+ emitRenderProgress(onProgress, 'proxy-generation', 'skipped', { reason: 'valid-proxy-cache' });
713
+ return context.cached;
714
+ }
715
+ let activeStage = 'proxy-generation';
716
+ try {
717
+ emitRenderProgress(onProgress, 'proxy-generation', 'started', { assetId });
718
+ const progressParser = onProgress
719
+ ? createFfmpegProgressParser(onProgress, 'proxy-generation', context.asset.durationSeconds, { assetId })
720
+ : undefined;
721
+ const args = onProgress ? [...context.args.slice(0, -1), '-progress', 'pipe:1', '-nostats', context.args.at(-1)] : context.args;
722
+ await runProcess('ffmpeg', args, {
723
+ signal,
724
+ ...(progressParser ? {
725
+ stdio: 'capture',
726
+ onStdout: progressParser.push,
727
+ onStderr: (chunk) => process.stderr.write(chunk),
728
+ } : {}),
729
+ });
730
+ progressParser?.flush();
731
+ emitRenderProgress(onProgress, 'proxy-generation', 'completed', { assetId });
732
+ activeStage = 'proxy-validation';
733
+ emitRenderProgress(onProgress, 'proxy-validation', 'started', { assetId });
734
+ const probe = await probeMediaAsync(context.stagedPath, { signal });
735
+ signal?.throwIfAborted();
736
+ const sha256 = await hashFileAsync(context.stagedPath, signal);
737
+ signal?.throwIfAborted();
738
+ emitRenderProgress(onProgress, 'proxy-validation', 'completed', { assetId });
739
+ activeStage = 'proxy-publish';
740
+ emitRenderProgress(onProgress, 'proxy-publish', 'started', { assetId });
741
+ const result = await storeAssetProxyAsync(context, probe, sha256, signal);
742
+ emitRenderProgress(onProgress, 'proxy-publish', 'completed', { assetId });
743
+ return result;
744
+ } catch (error) {
745
+ emitRenderProgress(onProgress, activeStage, 'failed', { assetId });
746
+ rollbackAssetProxy(context);
747
+ throw error;
748
+ } finally {
749
+ cleanupAssetProxy(context);
750
+ }
751
+ }
752
+
753
+ function prepareAssetProxy(projectPath, assetId, options) {
754
+ const details = inspectAssetProxyInputs(projectPath, assetId, options);
755
+ const sourceHash = hashFile(details.sourcePath);
756
+ const output = assetProxyOutput(details, sourceHash);
757
+ const existing = findAssetProxy(details.asset, details.maxDimension, details.crf, details.targetFrameRate,
758
+ details.keyframeIntervalSeconds, details.profileVersion, sourceHash);
759
+ const cachedProxyHash = existing && existing.path === output.relativePath && output.destinationExists
760
+ ? hashFile(output.destination)
761
+ : undefined;
762
+ return finalizeAssetProxyPreparation(details, output, sourceHash, existing, cachedProxyHash);
763
+ }
764
+
765
+ async function prepareAssetProxyAsync(projectPath, assetId, options, signal) {
766
+ const details = inspectAssetProxyInputs(projectPath, assetId, options);
767
+ const sourceHash = await hashFileAsync(details.sourcePath, signal);
768
+ signal?.throwIfAborted();
769
+ const output = assetProxyOutput(details, sourceHash);
770
+ const existing = findAssetProxy(details.asset, details.maxDimension, details.crf, details.targetFrameRate,
771
+ details.keyframeIntervalSeconds, details.profileVersion, sourceHash);
772
+ const cachedProxyHash = existing && existing.path === output.relativePath && output.destinationExists
773
+ ? await hashFileAsync(output.destination, signal)
774
+ : undefined;
775
+ signal?.throwIfAborted();
776
+ return finalizeAssetProxyPreparation(details, output, sourceHash, existing, cachedProxyHash);
777
+ }
778
+
779
+ function inspectAssetProxyInputs(projectPath, assetId, options) {
780
+ const root = resolve(projectPath);
781
+ const manifest = readAssetManifest(root);
782
+ const assetIndex = manifest.assets.findIndex((asset) => asset.id === assetId);
783
+ if (assetIndex < 0) throw new Error(`Asset is not registered: ${assetId}`);
784
+ const asset = manifest.assets[assetIndex];
785
+ if (asset.type !== 'video') throw new TypeError('Persistent proxies are supported for video assets.');
786
+ for (const field of Object.keys(options)) if (!['maxDimension', 'crf', 'frameRate'].includes(field)) throw new TypeError(`Unsupported proxy option: ${field}.`);
787
+ const { maxDimension = 960, crf = 28 } = options;
788
+ const targetFrameRate = options.frameRate === undefined ? undefined : String(normalizeFrameRate(options.frameRate));
789
+ const keyframeIntervalSeconds = PROXY_KEYFRAME_INTERVAL_SECONDS;
790
+ const profileVersion = PROXY_PROFILE_VERSION;
791
+ if (!Number.isSafeInteger(maxDimension) || maxDimension < 160 || maxDimension > 3840) throw new TypeError('Proxy maxDimension must be an integer from 160 to 3840.');
792
+ if (!Number.isSafeInteger(crf) || crf < 18 || crf > 40) throw new TypeError('Proxy CRF must be an integer from 18 to 40.');
793
+ const sourcePath = resolveProjectAsset(root, asset.path);
794
+ if (!existsSync(sourcePath) || !statSync(sourcePath).isFile() || lstatSync(sourcePath).isSymbolicLink()) throw new Error(`Asset ${assetId} is missing or not a regular project file.`);
795
+ return { root, manifest, assetIndex, asset, sourcePath, maxDimension, crf, targetFrameRate, keyframeIntervalSeconds, profileVersion };
796
+ }
797
+
798
+ function findAssetProxy(
799
+ asset,
800
+ maxDimension,
801
+ crf,
802
+ targetFrameRate,
803
+ keyframeIntervalSeconds,
804
+ profileVersion,
805
+ sourceHash,
806
+ ) {
807
+ return (asset.proxies ?? []).find((proxy) => proxy.maxDimension === maxDimension && proxy.crf === crf
808
+ && proxy.targetFrameRate === targetFrameRate
809
+ && proxy.keyframeIntervalSeconds === keyframeIntervalSeconds
810
+ && proxy.profileVersion === profileVersion
811
+ && proxy.sourceSha256 === sourceHash);
812
+ }
813
+
814
+ function assetProxyOutput(details, sourceHash) {
815
+ const { root, asset, maxDimension, crf, targetFrameRate, keyframeIntervalSeconds, profileVersion } = details;
816
+ const frameRateSuffix = targetFrameRate === undefined ? '' : `-fps${targetFrameRate.replace('/', '-')}`;
817
+ const gopSuffix = `-gop${keyframeIntervalSeconds}s`;
818
+ const fileName = `${asset.id}-p${profileVersion}-${maxDimension}-crf${crf}${frameRateSuffix}${gopSuffix}-${sourceHash.slice(0, 12)}.mp4`;
819
+ const relativePath = `assets/proxies/${fileName}`;
820
+ const destination = join(root, ...relativePath.split('/'));
821
+ const destinationExists = existsSync(destination);
822
+ if (destinationExists && lstatSync(destination).isSymbolicLink()) throw new TypeError('Proxy files cannot be symbolic links.');
823
+ return { relativePath, destination, destinationExists };
824
+ }
825
+
826
+ function finalizeAssetProxyPreparation(details, output, sourceHash, existing, cachedProxyHash) {
827
+ const { root, manifest, assetIndex, asset, sourcePath, maxDimension, crf, targetFrameRate,
828
+ keyframeIntervalSeconds, profileVersion } = details;
829
+ const assetId = asset.id;
830
+ const { relativePath, destination, destinationExists } = output;
831
+ if (sourceHash !== asset.sha256) throw new Error(`Asset ${asset.id} failed integrity verification; relink it before generating a proxy.`);
832
+ if (existing && existing.path === relativePath && destinationExists && cachedProxyHash === existing.sha256) return { cached: existing };
833
+ if (destinationExists && !existing) throw new Error(`Proxy file already exists and is not registered: ${relativePath}`);
834
+ const proxyDirectory = dirname(destination);
835
+ mkdirSync(proxyDirectory, { recursive: true });
836
+ if (lstatSync(proxyDirectory).isSymbolicLink()) throw new TypeError('Proxy directory cannot be a symbolic link.');
837
+ const stagedPath = join(proxyDirectory, `.${assetId}.${randomUUID()}.tmp.mp4`);
838
+ const backupPath = join(proxyDirectory, `.${assetId}.${randomUUID()}.backup.mp4`);
839
+ const filters = [
840
+ ...(targetFrameRate !== undefined ? [`fps=${targetFrameRate}`] : []),
841
+ `scale='min(iw,${maxDimension})':'min(ih,${maxDimension})':force_original_aspect_ratio=decrease:force_divisible_by=2`,
842
+ ];
843
+ const args = [
844
+ '-nostdin', '-hide_banner', '-v', 'error', '-y', '-i', sourcePath,
845
+ '-map', '0:v:0', '-vf', filters.join(','),
846
+ ...(targetFrameRate !== undefined ? ['-fps_mode', 'cfr'] : []),
847
+ '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', String(crf), '-pix_fmt', 'yuv420p',
848
+ '-force_key_frames', `expr:gte(t,n_forced*${keyframeIntervalSeconds})`,
849
+ '-movflags', '+faststart', stagedPath,
850
+ ];
851
+ return {
852
+ root,
853
+ manifest,
854
+ assetIndex,
855
+ asset,
856
+ sourceHash,
857
+ relativePath,
858
+ destination,
859
+ destinationExists,
860
+ stagedPath,
861
+ backupPath,
862
+ maxDimension,
863
+ crf,
864
+ targetFrameRate,
865
+ keyframeIntervalSeconds,
866
+ profileVersion,
867
+ args,
868
+ };
869
+ }
870
+
871
+ function storeAssetProxy(context, probe, sha256 = hashFile(context.stagedPath)) {
872
+ const { proxy, nextManifest } = buildAssetProxyRecord(context, probe, sha256);
873
+ const { root, stagedPath, destination, destinationExists, backupPath } = context;
874
+ if (destinationExists) copyFileSync(destination, backupPath, fsConstants.COPYFILE_EXCL);
875
+ copyFileSync(stagedPath, destination, destinationExists ? 0 : fsConstants.COPYFILE_EXCL);
876
+ writeAssetManifest(root, nextManifest);
877
+ return proxy;
878
+ }
879
+
880
+ async function storeAssetProxyAsync(context, probe, sha256, signal) {
881
+ const { proxy, nextManifest } = buildAssetProxyRecord(context, probe, sha256);
882
+ const { destination, destinationExists, backupPath, stagedPath, root } = context;
883
+ if (destinationExists) await rename(destination, backupPath);
884
+ try {
885
+ signal?.throwIfAborted();
886
+ await copyFile(stagedPath, destination, fsConstants.COPYFILE_EXCL);
887
+ signal?.throwIfAborted();
888
+ writeAssetManifest(root, nextManifest);
889
+ return proxy;
890
+ } catch (error) {
891
+ if (destinationExists && !existsSync(destination) && existsSync(backupPath)) await rename(backupPath, destination).catch(() => {});
892
+ throw error;
893
+ }
894
+ }
895
+
896
+ function buildAssetProxyRecord(context, probe, sha256) {
897
+ const { asset, assetIndex, root, manifest, stagedPath, destination, destinationExists,
898
+ sourceHash, relativePath, maxDimension, crf, targetFrameRate, keyframeIntervalSeconds, profileVersion } = context;
899
+ const expectedRatio = asset.width / asset.height;
900
+ const actualRatio = probe.width / probe.height;
901
+ if (probe.width > maxDimension || probe.height > maxDimension || Math.abs(expectedRatio - actualRatio) > 0.01) {
902
+ throw new Error('Generated video proxy has invalid dimensions or aspect ratio.');
903
+ }
904
+ const frameDuration = 1 / (asset.frameRate ?? 30);
905
+ if (Math.abs(probe.durationSeconds - asset.durationSeconds) > Math.max(0.15, frameDuration * 2)) {
906
+ throw new Error(`Generated video proxy duration ${probe.durationSeconds.toFixed(3)}s does not match source duration ${asset.durationSeconds}s.`);
907
+ }
908
+ if (Math.abs((probe.streamTiming.videoStartSeconds ?? 0) - (asset.streamTiming?.videoStartSeconds ?? 0)) > Math.max(0.05, frameDuration)) {
909
+ throw new Error('Generated video proxy does not preserve the source video start time.');
910
+ }
911
+ const proxy = {
912
+ path: relativePath,
913
+ maxDimension,
914
+ crf,
915
+ ...(targetFrameRate !== undefined ? { targetFrameRate } : {}),
916
+ keyframeIntervalSeconds,
917
+ profileVersion,
918
+ width: probe.width,
919
+ height: probe.height,
920
+ durationSeconds: probe.durationSeconds,
921
+ frameRate: probe.frameRate,
922
+ nominalFrameRate: probe.nominalFrameRate,
923
+ streamTiming: probe.streamTiming,
924
+ sizeBytes: statSync(stagedPath).size,
925
+ sha256,
926
+ sourceSha256: sourceHash,
927
+ };
928
+ const nextAsset = { ...asset, proxies: [...(asset.proxies ?? []).filter((entry) => !(entry.maxDimension === maxDimension
929
+ && entry.crf === crf && entry.targetFrameRate === targetFrameRate)), proxy] };
930
+ const nextManifest = { ...manifest, assets: manifest.assets.map((entry, index) => index === assetIndex ? nextAsset : entry) };
931
+ return { proxy, nextManifest };
932
+ }
933
+
934
+ function rollbackAssetProxy(context) {
935
+ if (context.destinationExists && existsSync(context.backupPath)) copyFileSync(context.backupPath, context.destination);
936
+ else if (!context.destinationExists) rmSync(context.destination, { force: true });
937
+ }
938
+
939
+ function cleanupAssetProxy(context) {
940
+ rmSync(context.stagedPath, { force: true });
941
+ rmSync(context.backupPath, { force: true });
942
+ }
943
+
944
+ export function resolveAssetProxies(projectPath, timeline) {
945
+ const root = resolve(projectPath);
946
+ const manifest = readAssetManifest(root);
947
+ const proxies = new Map();
948
+ const videoIds = new Set(timeline.tracks.filter((track) => track.kind === 'video').flatMap((track) => track.clips.map((clip) => clip.assetId)));
949
+ for (const assetId of videoIds) {
950
+ const entry = inspectAssetProxyForTimeline(root, manifest, timeline, assetId);
951
+ if (!entry) continue;
952
+ if (!isSafeRegularFile(entry.sourcePath) || hashFile(entry.sourcePath) !== entry.asset.sha256) {
953
+ throw new Error(`Video asset ${assetId} failed integrity verification; relink it before proxy rendering.`);
954
+ }
955
+ if (!entry.proxy) throw new Error(`Video asset ${assetId} has no current proxy; create one with "asset proxy create" first.`);
956
+ if (!isSafeRegularFile(entry.path) || hashFile(entry.path) !== entry.proxy.sha256) {
957
+ throw new Error(`Proxy for asset ${assetId} is missing or modified; regenerate it before preview rendering.`);
958
+ }
959
+ proxies.set(assetId, entry.proxy);
960
+ }
961
+ return proxies;
962
+ }
963
+
964
+ export async function resolveAssetProxiesAsync(projectPath, timeline, { signal } = {}) {
965
+ if (signal !== undefined && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function')) {
966
+ throw new TypeError('Proxy signal must be an AbortSignal.');
967
+ }
968
+ signal?.throwIfAborted();
969
+ const root = resolve(projectPath);
970
+ const manifest = readAssetManifest(root);
971
+ const proxies = new Map();
972
+ const videoIds = new Set(timeline.tracks.filter((track) => track.kind === 'video').flatMap((clipTrack) => clipTrack.clips.map((clip) => clip.assetId)));
973
+ for (const assetId of videoIds) {
974
+ signal?.throwIfAborted();
975
+ const entry = inspectAssetProxyForTimeline(root, manifest, timeline, assetId);
976
+ if (!entry) continue;
977
+ if (!isSafeRegularFile(entry.sourcePath) || await hashFileAsync(entry.sourcePath, signal) !== entry.asset.sha256) {
978
+ throw new Error(`Video asset ${assetId} failed integrity verification; relink it before proxy rendering.`);
979
+ }
980
+ if (!entry.proxy) throw new Error(`Video asset ${assetId} has no current proxy; create one with "asset proxy create" first.`);
981
+ if (!isSafeRegularFile(entry.path) || await hashFileAsync(entry.path, signal) !== entry.proxy.sha256) {
982
+ throw new Error(`Proxy for asset ${assetId} is missing or modified; regenerate it before preview rendering.`);
983
+ }
984
+ proxies.set(assetId, entry.proxy);
985
+ }
986
+ return proxies;
987
+ }
988
+
989
+ function inspectAssetProxyForTimeline(root, manifest, timeline, assetId) {
990
+ const asset = manifest.assets.find((entry) => entry.id === assetId);
991
+ if (asset?.type !== 'video') return undefined;
992
+ const sourcePath = resolveProjectAsset(root, asset.path);
993
+ const targetFrameRate = String(normalizeFrameRate(timeline.project.fps));
994
+ const proxy = asset.proxies?.filter((entry) => entry.sourceSha256 === asset.sha256
995
+ && (entry.targetFrameRate === undefined || entry.targetFrameRate === targetFrameRate))
996
+ .sort((left, right) => Number(right.targetFrameRate === targetFrameRate) - Number(left.targetFrameRate === targetFrameRate)
997
+ || left.maxDimension - right.maxDimension || right.crf - left.crf)[0];
998
+ if (!proxy) return { asset, sourcePath };
999
+ if (!/^[\da-f]{64}$/.test(proxy.sha256 ?? '') || !Number.isSafeInteger(proxy.maxDimension) || !Number.isSafeInteger(proxy.crf)) throw new TypeError(`Proxy metadata is invalid for asset ${assetId}.`);
1000
+ const parts = typeof proxy.path === 'string' ? proxy.path.split('/') : [];
1001
+ if (parts.length !== 3 || parts[0] !== 'assets' || parts[1] !== 'proxies' || parts.some((part) => !part || part === '.' || part === '..') || proxy.path.includes('\\')) {
1002
+ throw new TypeError(`Proxy path is invalid for asset ${assetId}.`);
1003
+ }
1004
+ const path = resolve(root, proxy.path);
1005
+ if (!path.startsWith(`${root}\\`) && !path.startsWith(`${root}/`)) throw new TypeError(`Proxy path escapes project: ${proxy.path}`);
1006
+ return { asset, sourcePath, proxy, path };
1007
+ }
1008
+
1009
+ function isSafeRegularFile(path) {
1010
+ return existsSync(path) && statSync(path).isFile() && !lstatSync(path).isSymbolicLink();
1011
+ }
1012
+
1013
+ function readAssetManifest(root) {
1014
+ const assetsPath = join(root, 'assets');
1015
+ if (!existsSync(join(root, 'meta.json')) || !existsSync(assetsPath) || !statSync(assetsPath).isDirectory() || lstatSync(assetsPath).isSymbolicLink()) throw new Error(`Not an Engine project: ${root}`);
1016
+ const manifestPath = join(root, 'assets', 'manifest.json');
1017
+ if (!existsSync(manifestPath)) return { schemaVersion: 1, assets: [] };
1018
+ if (lstatSync(manifestPath).isSymbolicLink()) throw new TypeError('Project asset manifest cannot be a symbolic link.');
1019
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1020
+ if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.assets)) throw new TypeError('Asset manifest must use schemaVersion 1 and an assets array.');
1021
+ const ids = new Set();
1022
+ for (const asset of manifest.assets) {
1023
+ if (!asset || !/^[a-z][a-z0-9-]*$/.test(asset.id ?? '') || ids.has(asset.id)
1024
+ || !['video', 'audio', 'image'].includes(asset.type) || !/^[\da-f]{64}$/.test(asset.sha256 ?? '')) {
1025
+ throw new TypeError('Asset manifest contains an invalid asset record.');
1026
+ }
1027
+ ids.add(asset.id);
1028
+ resolveProjectAsset(root, asset.path);
1029
+ if (asset.proxies !== undefined && !Array.isArray(asset.proxies)) throw new TypeError(`Asset ${asset.id} proxies must be an array.`);
1030
+ for (const proxy of asset.proxies ?? []) {
1031
+ if (!proxy || asset.type !== 'video' || !/^[\da-f]{64}$/.test(proxy.sha256 ?? '') || !/^[\da-f]{64}$/.test(proxy.sourceSha256 ?? '')
1032
+ || !Number.isSafeInteger(proxy.maxDimension) || proxy.maxDimension < 160 || proxy.maxDimension > 3840
1033
+ || !Number.isSafeInteger(proxy.crf) || proxy.crf < 18 || proxy.crf > 40
1034
+ || (proxy.targetFrameRate !== undefined && String(normalizeFrameRate(proxy.targetFrameRate)) !== proxy.targetFrameRate)
1035
+ || (proxy.profileVersion !== undefined && (!Number.isSafeInteger(proxy.profileVersion) || proxy.profileVersion < 1))
1036
+ || (proxy.keyframeIntervalSeconds !== undefined && (!Number.isSafeInteger(proxy.keyframeIntervalSeconds)
1037
+ || proxy.keyframeIntervalSeconds < 1 || proxy.keyframeIntervalSeconds > 60))) {
1038
+ throw new TypeError(`Asset ${asset.id} contains invalid proxy metadata.`);
1039
+ }
1040
+ resolveProjectProxy(root, proxy.path);
1041
+ }
1042
+ }
1043
+ return manifest;
1044
+ }
1045
+
1046
+ function resolveProjectProxy(root, relativePath) {
1047
+ const parts = typeof relativePath === 'string' ? relativePath.split('/') : [];
1048
+ if (parts.length !== 3 || parts[0] !== 'assets' || parts[1] !== 'proxies' || relativePath.includes('\\') || parts.some((part) => !part || part === '.' || part === '..')) {
1049
+ throw new TypeError('Proxy paths must stay inside assets/proxies.');
1050
+ }
1051
+ const path = resolve(root, relativePath);
1052
+ if (!path.startsWith(`${resolve(root)}\\`) && !path.startsWith(`${resolve(root)}/`)) throw new TypeError('Proxy path escapes the project.');
1053
+ const directory = dirname(path);
1054
+ if (existsSync(directory) && lstatSync(directory).isSymbolicLink()) throw new TypeError('Proxy directory cannot be a symbolic link.');
1055
+ return path;
1056
+ }
1057
+
1058
+ function writeAssetManifest(root, manifest) {
1059
+ const manifestPath = join(root, 'assets', 'manifest.json');
1060
+ writeFileAtomically(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
1061
+ }
1062
+
1063
+ function prepareTimelineAssetUpdate(root, asset) {
1064
+ const path = join(root, 'timeline.json');
1065
+ if (!existsSync(path)) return { path };
1066
+ if (lstatSync(path).isSymbolicLink()) throw new TypeError('Project timeline cannot be a symbolic link.');
1067
+ const original = readFileSync(path);
1068
+ const timeline = JSON.parse(original.toString('utf8'));
1069
+ const index = timeline.assets.findIndex((entry) => entry.id === asset.id);
1070
+ const assets = [...timeline.assets];
1071
+ if (index < 0) assets.push(asset);
1072
+ else assets[index] = asset;
1073
+ const next = validateTimeline({ ...timeline, assets });
1074
+ return { path, original, originalHistory: readTimelineHistorySnapshot(path), next };
1075
+ }
1076
+
1077
+ function rollbackAssetRegistration(destination, manifestPath, originalManifest, timelineUpdate, cause) {
1078
+ const rollbackErrors = [];
1079
+ try { rmSync(destination, { force: true }); } catch (error) { rollbackErrors.push(error); }
1080
+ try { restoreFile(manifestPath, originalManifest); } catch (error) { rollbackErrors.push(error); }
1081
+ try { restoreFile(timelineUpdate.path, timelineUpdate.original); } catch (error) { rollbackErrors.push(error); }
1082
+ try { restoreFile(getTimelineHistoryPath(timelineUpdate.path), timelineUpdate.originalHistory); } catch (error) { rollbackErrors.push(error); }
1083
+ if (rollbackErrors.length > 0) {
1084
+ throw new AggregateError([cause, ...rollbackErrors], `Asset registration failed and rollback was incomplete. Preserve project files and inspect ${destination}.`);
1085
+ }
1086
+ }
1087
+
1088
+ function checkpointTimelineHistory(timelineUpdate) {
1089
+ if (timelineUpdate.originalHistory === undefined) return;
1090
+ const history = JSON.parse(timelineUpdate.originalHistory.toString('utf8'));
1091
+ resetTimelineFileHistory(timelineUpdate.path, { limit: history.limit });
1092
+ }
1093
+
1094
+ function readTimelineHistorySnapshot(timelinePath) {
1095
+ const historyPath = getTimelineHistoryPath(timelinePath);
1096
+ if (!existsSync(historyPath)) return undefined;
1097
+ if (lstatSync(historyPath).isSymbolicLink()) throw new TypeError('Timeline history file cannot be a symbolic link.');
1098
+ return readFileSync(historyPath);
1099
+ }
1100
+
1101
+ function getTimelineHistoryPath(timelinePath) {
1102
+ return join(dirname(timelinePath), `.${basename(timelinePath)}.history.json`);
1103
+ }
1104
+
1105
+ function restoreFile(path, original) {
1106
+ if (original === undefined) rmSync(path, { force: true });
1107
+ else writeFileAtomically(path, original);
1108
+ }
1109
+
1110
+ function writeFileAtomically(path, content) {
1111
+ const temporaryPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
1112
+ try {
1113
+ writeFileSync(temporaryPath, content, { flag: 'wx' });
1114
+ renameSync(temporaryPath, path);
1115
+ } finally {
1116
+ rmSync(temporaryPath, { force: true });
1117
+ }
1118
+ }
1119
+
1120
+ function resolveProjectAsset(root, relativePath) {
1121
+ const parts = typeof relativePath === 'string' ? relativePath.split('/') : [];
1122
+ if (parts.length !== 2 || parts[0] !== 'assets' || relativePath.includes('\\') || parts.some((part) => !part || part === '.' || part === '..')) {
1123
+ throw new TypeError('Registered asset paths must remain project-relative.');
1124
+ }
1125
+ const path = resolve(root, relativePath);
1126
+ if (!path.startsWith(`${resolve(root)}\\`) && !path.startsWith(`${resolve(root)}/`)) throw new TypeError('Registered asset path escapes the project.');
1127
+ return path;
1128
+ }
1129
+
1130
+ function probeImage(path) {
1131
+ const result = spawnSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_name,width,height', '-of', 'json', path], { encoding: 'utf8', windowsHide: true });
1132
+ if (result.error || result.status !== 0) throw new Error(`ffprobe failed: ${result.error?.message ?? result.stderr.trim()}`);
1133
+ const stream = JSON.parse(result.stdout).streams?.[0];
1134
+ if (!Number.isSafeInteger(stream?.width) || stream.width <= 0 || !Number.isSafeInteger(stream?.height) || stream.height <= 0) throw new TypeError('Image must have readable positive dimensions.');
1135
+ return { width: stream.width, height: stream.height, codec: stream.codec_name ?? null };
1136
+ }
1137
+
1138
+ async function probeImageAsync(path, signal) {
1139
+ const result = await runProcess('ffprobe', [
1140
+ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_name,width,height', '-of', 'json', path,
1141
+ ], { signal, stdio: 'capture' });
1142
+ const stream = JSON.parse(result.stdout).streams?.[0];
1143
+ if (!Number.isSafeInteger(stream?.width) || stream.width <= 0 || !Number.isSafeInteger(stream?.height) || stream.height <= 0) throw new TypeError('Image must have readable positive dimensions.');
1144
+ return { width: stream.width, height: stream.height, codec: stream.codec_name ?? null };
1145
+ }
1146
+
1147
+ async function copyFileAndHash(source, destination, signal, onProgress) {
1148
+ const hash = createHash('sha256');
1149
+ const totalBytes = statSync(source).size;
1150
+ let copiedBytes = 0;
1151
+ let lastPercent = -1;
1152
+ const hasher = new Transform({
1153
+ transform(chunk, encoding, callback) {
1154
+ hash.update(chunk);
1155
+ copiedBytes += chunk.length;
1156
+ const percent = totalBytes > 0 ? Math.floor(copiedBytes / totalBytes * 100) : 100;
1157
+ if (onProgress && (percent > lastPercent || copiedBytes >= totalBytes)) {
1158
+ lastPercent = percent;
1159
+ onProgress(copiedBytes);
1160
+ }
1161
+ callback(null, chunk);
1162
+ },
1163
+ });
1164
+ await pipeline(createReadStream(source), hasher, createWriteStream(destination, { flags: 'wx' }), { signal });
1165
+ return hash.digest('hex');
1166
+ }
1167
+
1168
+ function hashFile(path) {
1169
+ const hash = createHash('sha256');
1170
+ const descriptor = openSync(path, 'r');
1171
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
1172
+ try {
1173
+ let bytes;
1174
+ while ((bytes = readSync(descriptor, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, bytes));
1175
+ } finally {
1176
+ closeSync(descriptor);
1177
+ }
1178
+ return hash.digest('hex');
1179
+ }
1180
+
1181
+ async function hashFileAsync(path, signal, onBytes) {
1182
+ const hash = createHash('sha256');
1183
+ for await (const chunk of createReadStream(path, { signal })) {
1184
+ hash.update(chunk);
1185
+ onBytes?.(chunk.length);
1186
+ }
1187
+ signal?.throwIfAborted();
1188
+ return hash.digest('hex');
1189
+ }
1190
+
1191
+ function defaultAssetId(path) {
1192
+ const id = basename(path, extname(path)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
1193
+ return /^[a-z]/.test(id) ? id : `asset-${id || 'media'}`;
1194
+ }