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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +118 -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 +61 -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,590 +1,590 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { createRequire } from 'node:module';
3
- import { closeSync, copyFileSync, cpSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
4
- import { mkdir, mkdtemp, open, readdir, rename, rm } from 'node:fs/promises';
5
- import { pipeline } from 'node:stream/promises';
6
- import { tmpdir } from 'node:os';
7
- import { basename, dirname, extname, join, resolve } from 'node:path';
8
- import { probeMedia, probeMediaAsync } from '../media/probe.mjs';
9
- import { createFfmpegProgressParser, emitRenderProgress } from './progress.mjs';
10
- import { normalizeTranscript } from '../transcript/normalize.mjs';
11
- import { parseSubtitleText } from '../transcript/subtitles.mjs';
12
- import { frameRateNumber } from '../media/frame-rate.mjs';
13
- import { resolveColorProfile, resolveEncoderArgs, resolveProfileEncoder, resolveRenderControlArgs, resolveRenderFormat, resolveVideoEncoder } from './formats.mjs';
14
- import { hasFfmpegEncoder, hasFfmpegFilter } from '../media/ffmpeg-capabilities.mjs';
15
- import { runProcess } from './process.mjs';
16
- import { fingerprintRenderProject, prepareRenderCheckpoint, removeRenderCheckpoint, restoreRenderCheckpoint, saveRenderCheckpoint } from './checkpoints.mjs';
17
-
18
- const require = createRequire(import.meta.url);
19
-
20
- export function lintProject(projectPath) {
21
- return runHyperframes(['lint'], projectPath);
22
- }
23
-
24
- export function previewProject(projectPath) {
25
- return runHyperframes(['preview'], projectPath);
26
- }
27
-
28
- export function doctorHyperframes(projectPath = process.cwd()) {
29
- return runHyperframes(['doctor'], projectPath);
30
- }
31
-
32
- export function importProjectTranscript(projectPath, mediaPath, transcriptPath) {
33
- const project = resolve(projectPath);
34
- const media = probeMedia(mediaPath, { requireVideo: false });
35
- const inputPath = resolve(transcriptPath);
36
- const inputExtension = extname(inputPath).toLowerCase();
37
- if (!existsSync(join(project, 'meta.json')) || !existsSync(join(project, 'index.html'))) throw new Error(`Not a video project: ${project}`);
38
- const rawPath = join(project, 'transcript.json');
39
- const outputPath = join(project, 'transcripts', 'transcript.json');
40
- if (existsSync(rawPath) || existsSync(outputPath)) {
41
- throw new Error('This project already has a transcript; create a new project to preserve the existing edit.');
42
- }
43
- const granularity = ['.srt', '.vtt'].includes(inputExtension) ? 'cue' : 'word';
44
- const raw = granularity === 'cue'
45
- ? parseSubtitleText(readFileSync(inputPath, 'utf8'), inputExtension.slice(1))
46
- : JSON.parse(readFileSync(inputPath, 'utf8'));
47
- const transcript = normalizeTranscript(raw, media.durationSeconds, granularity);
48
- writeFileSync(outputPath, `${JSON.stringify(transcript, null, 2)}\n`, { flag: 'wx' });
49
- return { transcriptPath: outputPath, wordCount: transcript.words.length, durationSeconds: transcript.durationSeconds };
50
- }
51
-
52
- export function transcribeProject(projectPath, mediaPath, options = {}) {
53
- const project = resolve(projectPath);
54
- const media = probeMedia(mediaPath, { requireVideo: false });
55
- const args = ['transcribe', resolve(mediaPath), '--dir', project, '--json'];
56
- if (options.model) args.push('--model', options.model);
57
- if (options.language) args.push('--language', options.language);
58
- return processTranscript(project, resolve(mediaPath), media.durationSeconds, args);
59
- }
60
-
61
- export function renderProject(projectPath, outputPath, quality = 'draft', options = {}) {
62
- if (options.resumeCheckpoint === true) throw new TypeError('Resumable checkpoints require renderProjectAsync.');
63
- if (options.signal !== undefined || options.timeoutSeconds !== undefined) {
64
- throw new TypeError('Cancellation and render timeouts require renderProjectAsync.');
65
- }
66
- if (options.onProgress !== undefined) throw new TypeError('Render progress callbacks require renderProjectAsync.');
67
- const project = resolve(projectPath);
68
- const indexPath = join(project, 'index.html');
69
- const metaPath = join(project, 'meta.json');
70
- if (!existsSync(indexPath) || !existsSync(metaPath)) {
71
- throw new Error(`Not a video project: ${project}`);
72
- }
73
- if (!['draft', 'standard', 'high'].includes(quality)) {
74
- throw new TypeError('Quality must be draft, standard, or high.');
75
- }
76
- const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
77
- const output = resolve(outputPath ?? join(project, `renders/draft.${options.format ?? 'mp4'}`));
78
- const format = resolveRenderFormat(output, options.format);
79
- const encoderSettings = options.encoder ?? {};
80
- const videoEncoder = resolveVideoEncoder(format, encoderSettings);
81
- const colorProfile = resolveColorProfile(options.colorProfile, format);
82
- const finalEncoder = colorProfile ? resolveProfileEncoder(format, videoEncoder, colorProfile) : videoEncoder;
83
- if (finalEncoder.codec) hasFfmpegEncoder(finalEncoder.codec);
84
- if (colorProfile && !hasFfmpegFilter(colorProfile.requiredFilter)) throw new Error(`The ${colorProfile.id} color profile requires an FFmpeg build with the ${colorProfile.requiredFilter} filter.`);
85
- const encoderArgs = resolveEncoderArgs(format, encoderSettings);
86
- const renderControlArgs = resolveRenderControlArgs(options);
87
- if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
88
- const outputDir = dirname(output);
89
- mkdirSync(outputDir, { recursive: true });
90
- const pngSequence = format === 'png-sequence';
91
- const temporaryDirectory = mkdtempSync(join(tmpdir(), 'aive-render-'));
92
- const temporaryOutput = pngSequence
93
- ? join(temporaryDirectory, 'frames')
94
- : join(temporaryDirectory, `render.${format}`);
95
- const encodedOutput = finalEncoder.codec
96
- ? join(temporaryDirectory, `encoded.${format}`)
97
- : undefined;
98
- let outputCreated = false;
99
-
100
- try {
101
- runHyperframes(['render', '--format', format, '--fps', String(meta.fps), '--quality', quality, ...renderControlArgs, ...encoderArgs, '--output', temporaryOutput], project);
102
- if (pngSequence) {
103
- const frames = readdirSync(temporaryOutput, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.png'));
104
- const expectedFrames = Math.round(meta.durationSeconds * frameRateNumber(meta.fps ?? 30));
105
- if (frames.length !== expectedFrames || frames.length === 0) throw new Error(`PNG sequence has ${frames.length} frames; expected ${expectedFrames}.`);
106
- for (const frame of frames) {
107
- const data = readFileSync(join(temporaryOutput, frame.name));
108
- if (data.length < 24 || data.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a'
109
- || data.readUInt32BE(16) !== meta.width || data.readUInt32BE(20) !== meta.height) {
110
- throw new Error(`PNG sequence frame ${frame.name} has an invalid signature or dimensions.`);
111
- }
112
- }
113
- if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
114
- publishPngSequenceSync(temporaryOutput, output, outputDir);
115
- return { project, output, format, durationSeconds: meta.durationSeconds, width: meta.width, height: meta.height, frameRate: frameRateNumber(meta.fps), frameCount: frames.length };
116
- }
117
- if (finalEncoder.codec) encodeVideoFile(temporaryOutput, encodedOutput, format, finalEncoder, colorProfile);
118
- const rendered = probeMedia(encodedOutput ?? temporaryOutput);
119
- const expectedFps = frameRateNumber(meta.fps ?? 30);
120
- if (Math.abs(rendered.durationSeconds - meta.durationSeconds) > Math.max(0.15, 1 / expectedFps)) {
121
- throw new Error(`Rendered duration ${rendered.durationSeconds.toFixed(3)}s does not match project duration ${meta.durationSeconds}s.`);
122
- }
123
- if (rendered.width !== meta.width || rendered.height !== meta.height) {
124
- throw new Error(`Rendered canvas ${rendered.width}×${rendered.height} does not match project canvas ${meta.width}×${meta.height}.`);
125
- }
126
- const renderedFrameRate = rendered.videoCodec === 'gif'
127
- ? rendered.nominalFrameRate ?? rendered.frameRate
128
- : rendered.frameRate;
129
- if (renderedFrameRate && Math.abs(renderedFrameRate - expectedFps) > 0.001) {
130
- throw new Error(`Rendered frame rate ${renderedFrameRate} fps does not match project frame rate ${meta.fps} fps.`);
131
- }
132
- const expectedCodec = { h264: 'h264', h265: 'hevc', vp9: 'vp9', prores: 'prores' }[finalEncoder.codecId];
133
- if (expectedCodec && rendered.videoCodec !== expectedCodec) {
134
- throw new Error(`Rendered video codec ${rendered.videoCodec} does not match requested codec ${finalEncoder.codecId}.`);
135
- }
136
- validateColorProfile(rendered, colorProfile);
137
- closeSync(openSync(output, 'wx'));
138
- outputCreated = true;
139
- copyFileSync(encodedOutput ?? temporaryOutput, output);
140
- return { project, output, format, durationSeconds: rendered.durationSeconds, width: rendered.width, height: rendered.height, frameRate: rendered.frameRate, videoCodec: rendered.videoCodec, ...(colorProfile ? { colorProfile: colorProfile.id } : {}) };
141
- } catch (error) {
142
- if (outputCreated && existsSync(output)) unlinkSync(output);
143
- throw error;
144
- } finally {
145
- cleanupTemporaryRenderOutputs(outputDir, temporaryOutput, encodedOutput, temporaryDirectory);
146
- }
147
- }
148
-
149
- export async function renderProjectAsync(projectPath, outputPath, quality = 'draft', options = {}) {
150
- const project = resolve(projectPath);
151
- const indexPath = join(project, 'index.html');
152
- const metaPath = join(project, 'meta.json');
153
- if (!existsSync(indexPath) || !existsSync(metaPath)) throw new Error(`Not a video project: ${project}`);
154
- if (!['draft', 'standard', 'high'].includes(quality)) throw new TypeError('Quality must be draft, standard, or high.');
155
-
156
- const { signal: externalSignal, onProgress, ...renderOptions } = options;
157
- if (externalSignal !== undefined && (typeof externalSignal.aborted !== 'boolean' || typeof externalSignal.addEventListener !== 'function')) {
158
- throw new TypeError('Render signal must be an AbortSignal.');
159
- }
160
- if (onProgress !== undefined && typeof onProgress !== 'function') throw new TypeError('Render onProgress must be a function.');
161
- const controls = resolveRenderControlArgs(renderOptions);
162
- const resumeCheckpoint = renderOptions.resumeCheckpoint === true;
163
- const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
164
- const output = resolve(outputPath ?? join(project, `renders/draft.${renderOptions.format ?? 'mp4'}`));
165
- const format = resolveRenderFormat(output, renderOptions.format);
166
- const encoderSettings = renderOptions.encoder ?? {};
167
- const videoEncoder = resolveVideoEncoder(format, encoderSettings);
168
- const colorProfile = resolveColorProfile(renderOptions.colorProfile, format);
169
- const finalEncoder = colorProfile ? resolveProfileEncoder(format, videoEncoder, colorProfile) : videoEncoder;
170
- if (finalEncoder.codec) hasFfmpegEncoder(finalEncoder.codec);
171
- if (colorProfile && !hasFfmpegFilter(colorProfile.requiredFilter)) throw new Error(`The ${colorProfile.id} color profile requires an FFmpeg build with the ${colorProfile.requiredFilter} filter.`);
172
- const encoderArgs = resolveEncoderArgs(format, encoderSettings);
173
- if (resumeCheckpoint && format === 'png-sequence') throw new TypeError('Resumable checkpoints do not support PNG sequence output.');
174
- if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
175
- const outputDir = dirname(output);
176
- mkdirSync(outputDir, { recursive: true });
177
- const pngSequence = format === 'png-sequence';
178
- let temporaryDirectory;
179
- let temporaryOutput;
180
- let encodedOutput;
181
- let checkpoint;
182
- let checkpointArtifact;
183
- let resumedFromCheckpoint = false;
184
- if (resumeCheckpoint) {
185
- const fingerprint = await fingerprintRenderProject(project, {
186
- quality,
187
- format,
188
- fps: meta.fps,
189
- width: meta.width,
190
- height: meta.height,
191
- durationSeconds: meta.durationSeconds,
192
- encoder: encoderSettings,
193
- colorProfile: colorProfile?.id,
194
- workers: renderOptions.workers ?? 'auto',
195
- lowMemoryMode: renderOptions.lowMemoryMode ?? process.env.PRODUCER_LOW_MEMORY_MODE ?? 'default',
196
- platform: process.platform,
197
- arch: process.arch,
198
- node: process.version,
199
- hyperframes: JSON.parse(readFileSync(resolveHyperframesPackage(), 'utf8')).version,
200
- });
201
- checkpoint = await prepareRenderCheckpoint(output, fingerprint, {
202
- ...(renderOptions.checkpointMaxBytes !== undefined ? { maxBytes: renderOptions.checkpointMaxBytes } : {}),
203
- ...(renderOptions.checkpointMaxAgeHours !== undefined ? { maxAgeHours: renderOptions.checkpointMaxAgeHours } : {}),
204
- });
205
- checkpointArtifact = await restoreRenderCheckpoint(checkpoint);
206
- resumedFromCheckpoint = Boolean(checkpointArtifact);
207
- }
208
- let outputCreated = false;
209
- let completed = false;
210
- const control = createRenderControl(externalSignal, renderOptions.timeoutSeconds);
211
-
212
- try {
213
- temporaryDirectory = await mkdtemp(join(tmpdir(), 'aive-render-'));
214
- temporaryOutput = pngSequence
215
- ? join(temporaryDirectory, 'frames')
216
- : join(temporaryDirectory, `render.${format}`);
217
- encodedOutput = finalEncoder.codec
218
- ? join(temporaryDirectory, `encoded.${format}`)
219
- : undefined;
220
- if (!checkpointArtifact) {
221
- emitRenderProgress(onProgress, 'capture', 'started');
222
- await runHyperframesAsync(['render', '--format', format, '--fps', String(meta.fps), '--quality', quality, ...controls, ...encoderArgs, '--output', temporaryOutput], project, control.signal, onProgress);
223
- emitRenderProgress(onProgress, 'capture', 'completed');
224
- if (checkpoint) {
225
- emitRenderProgress(onProgress, 'validation', 'started', { target: 'checkpoint-capture' });
226
- validateRenderedMedia(await probeMediaAsync(temporaryOutput, { signal: control.signal }), meta);
227
- emitRenderProgress(onProgress, 'validation', 'completed', { target: 'checkpoint-capture' });
228
- checkpointArtifact = await saveRenderCheckpoint(checkpoint, temporaryOutput);
229
- }
230
- } else {
231
- emitRenderProgress(onProgress, 'capture', 'skipped', { reason: 'checkpoint-resume' });
232
- }
233
- if (pngSequence) {
234
- emitRenderProgress(onProgress, 'validation', 'started', { target: 'png-sequence' });
235
- const frames = readdirSync(temporaryOutput, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.png'));
236
- const expectedFrames = Math.round(meta.durationSeconds * frameRateNumber(meta.fps ?? 30));
237
- if (frames.length !== expectedFrames || frames.length === 0) throw new Error(`PNG sequence has ${frames.length} frames; expected ${expectedFrames}.`);
238
- for (const frame of frames) {
239
- control.signal.throwIfAborted();
240
- if (!await hasExpectedPngHeader(join(temporaryOutput, frame.name), meta.width, meta.height)) {
241
- throw new Error(`PNG sequence frame ${frame.name} has an invalid signature or dimensions.`);
242
- }
243
- }
244
- emitRenderProgress(onProgress, 'validation', 'completed', { target: 'png-sequence', completed: frames.length, total: frames.length });
245
- if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
246
- control.signal.throwIfAborted();
247
- emitRenderProgress(onProgress, 'publish', 'started');
248
- await publishPngSequenceAsync(temporaryOutput, output, outputDir, control.signal);
249
- outputCreated = true;
250
- control.signal.throwIfAborted();
251
- emitRenderProgress(onProgress, 'publish', 'completed');
252
- return { project, output, format, durationSeconds: meta.durationSeconds, width: meta.width, height: meta.height, frameRate: frameRateNumber(meta.fps), frameCount: frames.length };
253
- }
254
- const renderArtifact = checkpointArtifact ?? temporaryOutput;
255
- if (finalEncoder.codec) {
256
- emitRenderProgress(onProgress, 'encoding', 'started', { codec: finalEncoder.codecId });
257
- await encodeVideoFileAsync(renderArtifact, encodedOutput, format, finalEncoder, control.signal, colorProfile, meta.durationSeconds, onProgress);
258
- emitRenderProgress(onProgress, 'encoding', 'completed', { codec: finalEncoder.codecId });
259
- } else emitRenderProgress(onProgress, 'encoding', 'skipped', { reason: 'renderer-output-is-final' });
260
- emitRenderProgress(onProgress, 'validation', 'started', { target: 'final-output' });
261
- const rendered = await probeMediaAsync(encodedOutput ?? renderArtifact, { signal: control.signal });
262
- validateRenderedMedia(rendered, meta, finalEncoder.codecId);
263
- validateColorProfile(rendered, colorProfile);
264
- emitRenderProgress(onProgress, 'validation', 'completed', { target: 'final-output' });
265
- const outputFile = await open(output, 'wx');
266
- outputCreated = true;
267
- emitRenderProgress(onProgress, 'publish', 'started');
268
- await pipeline(createReadStream(encodedOutput ?? renderArtifact, { signal: control.signal }),
269
- outputFile.createWriteStream(), { signal: control.signal });
270
- emitRenderProgress(onProgress, 'publish', 'completed');
271
- completed = true;
272
- return { project, output, format, durationSeconds: rendered.durationSeconds, width: rendered.width, height: rendered.height, frameRate: rendered.frameRate, videoCodec: rendered.videoCodec, ...(colorProfile ? { colorProfile: colorProfile.id } : {}), ...(resumeCheckpoint ? { resumedFromCheckpoint } : {}) };
273
- } catch (error) {
274
- if (outputCreated && existsSync(output)) await rm(output, { recursive: true, force: true });
275
- if (checkpointArtifact) error.message += ' The completed capture is preserved for resume; retry the same render options to reuse it.';
276
- throw error;
277
- } finally {
278
- if (temporaryOutput) await cleanupTemporaryRenderOutputsAsync(outputDir, temporaryOutput, encodedOutput, temporaryDirectory);
279
- if (checkpoint && completed) {
280
- await removeRenderCheckpoint(checkpoint).catch((error) => process.emitWarning(`Could not remove completed render checkpoint: ${error.message}`));
281
- }
282
- control.dispose();
283
- }
284
- }
285
-
286
- async function hasExpectedPngHeader(path, width, height) {
287
- const file = await open(path, 'r');
288
- try {
289
- const header = Buffer.alloc(24);
290
- const { bytesRead } = await file.read(header, 0, header.length, 0);
291
- return bytesRead === header.length && header.subarray(0, 8).toString('hex') === '89504e470d0a1a0a'
292
- && header.readUInt32BE(16) === width && header.readUInt32BE(20) === height;
293
- } finally {
294
- await file.close();
295
- }
296
- }
297
-
298
- async function cleanupTemporaryRenderOutputsAsync(outputDir, temporaryOutput, encodedOutput, temporaryDirectory) {
299
- await rm(temporaryOutput, { recursive: true, force: true });
300
- const outputName = basename(temporaryOutput);
301
- const transactionStem = outputName.replace(/\.(?:mp4|webm|mov|gif)$/i, '');
302
- const transactionPrefix = `.${transactionStem}.hf-transaction-`;
303
- const transactionDirectory = temporaryDirectory ?? outputDir;
304
- if (existsSync(transactionDirectory)) {
305
- for (const entry of await readdir(transactionDirectory, { withFileTypes: true })) {
306
- if (entry.isDirectory() && entry.name.startsWith(transactionPrefix)) {
307
- await rm(join(transactionDirectory, entry.name), { recursive: true, force: true });
308
- }
309
- }
310
- }
311
- if (encodedOutput) await rm(encodedOutput, { force: true });
312
- if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true });
313
- }
314
-
315
- function validateRenderedMedia(rendered, meta, codecId) {
316
- const expectedFps = frameRateNumber(meta.fps ?? 30);
317
- if (Math.abs(rendered.durationSeconds - meta.durationSeconds) > Math.max(0.15, 1 / expectedFps)) throw new Error(`Rendered duration ${rendered.durationSeconds.toFixed(3)}s does not match project duration ${meta.durationSeconds}s.`);
318
- if (rendered.width !== meta.width || rendered.height !== meta.height) throw new Error(`Rendered canvas ${rendered.width}×${rendered.height} does not match project canvas ${meta.width}×${meta.height}.`);
319
- const renderedFrameRate = rendered.videoCodec === 'gif'
320
- ? rendered.nominalFrameRate ?? rendered.frameRate
321
- : rendered.frameRate;
322
- if (renderedFrameRate && Math.abs(renderedFrameRate - expectedFps) > 0.001) throw new Error(`Rendered frame rate ${renderedFrameRate} fps does not match project frame rate ${meta.fps} fps.`);
323
- const expectedCodec = { h264: 'h264', h265: 'hevc', vp9: 'vp9', prores: 'prores' }[codecId];
324
- if (expectedCodec && rendered.videoCodec !== expectedCodec) throw new Error(`Rendered video codec ${rendered.videoCodec} does not match requested codec ${codecId}.`);
325
- }
326
-
327
- function validateColorProfile(rendered, colorProfile) {
328
- if (!colorProfile) return;
329
- for (const field of ['colorSpace', 'colorTransfer', 'colorPrimaries', 'colorRange', ...(colorProfile.pixelFormat ? ['pixelFormat'] : [])]) {
330
- if (rendered[field] !== colorProfile[field]) {
331
- throw new Error(`Rendered ${field} ${rendered[field] ?? 'unknown'} does not match requested ${colorProfile.id} profile (${colorProfile[field]}).`);
332
- }
333
- }
334
- }
335
-
336
- function cleanupTemporaryRenderOutputs(outputDir, temporaryOutput, encodedOutput, temporaryDirectory) {
337
- rmSync(temporaryOutput, { recursive: true, force: true });
338
- const outputName = basename(temporaryOutput);
339
- const transactionStem = outputName.replace(/\.(?:mp4|webm|mov|gif)$/i, '');
340
- const transactionPrefix = `.${transactionStem}.hf-transaction-`;
341
- const transactionDirectory = temporaryDirectory ?? outputDir;
342
- if (existsSync(transactionDirectory)) {
343
- for (const entry of readdirSync(transactionDirectory, { withFileTypes: true })) {
344
- if (entry.isDirectory() && entry.name.startsWith(transactionPrefix)) {
345
- rmSync(join(transactionDirectory, entry.name), { recursive: true, force: true });
346
- }
347
- }
348
- }
349
- if (encodedOutput) rmSync(encodedOutput, { force: true });
350
- if (temporaryDirectory) rmSync(temporaryDirectory, { recursive: true, force: true });
351
- }
352
-
353
- function publishPngSequenceSync(temporaryOutput, output, outputDir) {
354
- const stagingDirectory = mkdtempSync(join(outputDir, '.aive-sequence-'));
355
- const stagingOutput = join(stagingDirectory, 'frames');
356
- try {
357
- cpSync(temporaryOutput, stagingOutput, { recursive: true, force: false, errorOnExist: true });
358
- if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
359
- renameSync(stagingOutput, output);
360
- } finally {
361
- rmSync(stagingDirectory, { recursive: true, force: true });
362
- }
363
- }
364
-
365
- async function publishPngSequenceAsync(temporaryOutput, output, outputDir, signal) {
366
- const stagingDirectory = await mkdtemp(join(outputDir, '.aive-sequence-'));
367
- const stagingOutput = join(stagingDirectory, 'frames');
368
- try {
369
- await mkdir(stagingOutput);
370
- for (const entry of await readdir(temporaryOutput, { withFileTypes: true })) {
371
- if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.png')) continue;
372
- signal.throwIfAborted();
373
- await pipeline(
374
- createReadStream(join(temporaryOutput, entry.name), { signal }),
375
- createWriteStream(join(stagingOutput, entry.name), { flags: 'wx' }),
376
- { signal },
377
- );
378
- }
379
- signal.throwIfAborted();
380
- if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
381
- await rename(stagingOutput, output);
382
- } finally {
383
- await rm(stagingDirectory, { recursive: true, force: true });
384
- }
385
- }
386
-
387
- function createRenderControl(externalSignal, timeoutSeconds) {
388
- const controller = new AbortController();
389
- const relayAbort = () => controller.abort(externalSignal.reason);
390
- if (externalSignal?.aborted) relayAbort();
391
- else externalSignal?.addEventListener('abort', relayAbort, { once: true });
392
- const timer = timeoutSeconds === undefined ? undefined : setTimeout(() => {
393
- const error = new Error(`Render exceeded the configured timeout of ${timeoutSeconds} seconds.`);
394
- error.name = 'TimeoutError';
395
- controller.abort(error);
396
- }, timeoutSeconds * 1000);
397
- timer?.unref();
398
- return {
399
- signal: controller.signal,
400
- dispose() {
401
- if (timer) clearTimeout(timer);
402
- externalSignal?.removeEventListener('abort', relayAbort);
403
- },
404
- };
405
- }
406
-
407
- async function encodeVideoFileAsync(inputPath, outputPath, format, encoder, signal, colorProfile, durationSeconds, onProgress) {
408
- const args = [
409
- '-nostdin', '-hide_banner', '-v', 'error', '-y', '-i', inputPath,
410
- '-map', '0:v:0', '-map', '0:a?', '-c:v', encoder.codec,
411
- ];
412
- appendVideoProfileArgs(args, encoder, colorProfile);
413
- if (encoder.crf !== undefined) {
414
- args.push('-crf', String(encoder.crf));
415
- if (encoder.codecId === 'vp9') args.push('-b:v', '0');
416
- }
417
- if (encoder.videoBitrate !== undefined) args.push('-b:v', encoder.videoBitrate);
418
- args.push('-c:a', 'copy', '-progress', 'pipe:1', '-nostats');
419
- if (format === 'mp4' || format === 'mov') args.push('-movflags', '+faststart');
420
- args.push(outputPath);
421
- const progressParser = onProgress ? createFfmpegProgressParser(onProgress, 'encoding', durationSeconds, { codec: encoder.codecId }) : undefined;
422
- await runProcess('ffmpeg', args, {
423
- signal,
424
- ...(progressParser ? {
425
- stdio: 'capture',
426
- onStdout: progressParser.push,
427
- onStderr: (chunk) => process.stderr.write(chunk),
428
- } : {}),
429
- });
430
- progressParser?.flush();
431
- }
432
-
433
- function encodeVideoFile(inputPath, outputPath, format, encoder, colorProfile) {
434
- const args = [
435
- '-nostdin', '-hide_banner', '-v', 'error', '-y', '-i', inputPath,
436
- '-map', '0:v:0', '-map', '0:a?', '-c:v', encoder.codec,
437
- ];
438
- appendVideoProfileArgs(args, encoder, colorProfile);
439
- if (encoder.crf !== undefined) {
440
- args.push('-crf', String(encoder.crf));
441
- if (encoder.codecId === 'vp9') args.push('-b:v', '0');
442
- }
443
- if (encoder.videoBitrate !== undefined) args.push('-b:v', encoder.videoBitrate);
444
- args.push('-c:a', 'copy');
445
- if (format === 'mp4' || format === 'mov') args.push('-movflags', '+faststart');
446
- args.push(outputPath);
447
- const result = spawnSync('ffmpeg', args, { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
448
- if (result.error || result.status !== 0) {
449
- throw new Error(`Could not encode ${encoder.codecId} video: ${result.error?.message ?? result.stderr.trim()}`);
450
- }
451
- }
452
-
453
- function appendVideoProfileArgs(args, encoder, colorProfile) {
454
- if (encoder.codecId === 'prores') args.push('-profile:v', '3', '-pix_fmt', 'yuv422p10le');
455
- else args.push('-pix_fmt', colorProfile?.pixelFormat ?? 'yuv420p');
456
- if (colorProfile) {
457
- args.push('-vf', colorProfile.filter);
458
- if (colorProfile.id === 'hdr10') {
459
- args.push('-profile:v', 'main10', '-x265-params', `hdr10=1:master-display=${colorProfile.masteringDisplay}:max-cll=${colorProfile.contentLightLevel}`);
460
- }
461
- args.push('-color_primaries', colorProfile.colorPrimaries, '-color_trc', colorProfile.colorTransfer, '-colorspace', colorProfile.colorSpace, '-color_range', colorProfile.colorRange);
462
- }
463
- }
464
-
465
- function runHyperframes(args, projectPath) {
466
- const project = resolve(projectPath);
467
- const packagePath = resolveHyperframesPackage();
468
- const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
469
- const bin = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.hyperframes;
470
- if (!bin) throw new Error('Installed HyperFrames package has no CLI entry point.');
471
- const cliPath = resolve(dirname(packagePath), bin);
472
- const result = spawnSync(process.execPath, [cliPath, ...args], {
473
- cwd: project,
474
- stdio: 'inherit',
475
- windowsHide: false,
476
- });
477
- if (result.error || result.status !== 0) {
478
- throw new Error(`HyperFrames ${args[0]} failed: ${result.error?.message ?? `exit code ${result.status}`}`);
479
- }
480
- return true;
481
- }
482
-
483
- async function runHyperframesAsync(args, projectPath, signal, onProgress) {
484
- const project = resolve(projectPath);
485
- const packagePath = resolveHyperframesPackage();
486
- const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
487
- const bin = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.hyperframes;
488
- if (!bin) throw new Error('Installed HyperFrames package has no CLI entry point.');
489
- const cliPath = resolve(dirname(packagePath), bin);
490
- const progressParser = onProgress ? createHyperframesProgressParser(onProgress) : undefined;
491
- try {
492
- await runProcess(process.execPath, [cliPath, ...args], {
493
- cwd: project,
494
- signal,
495
- windowsHide: false,
496
- ...(progressParser ? {
497
- stdio: 'capture',
498
- onStdout: progressParser.push,
499
- onStderr: (chunk) => process.stderr.write(chunk),
500
- } : {}),
501
- });
502
- progressParser?.flush();
503
- } catch (error) {
504
- if (error.name === 'AbortError' || error.name === 'TimeoutError') throw error;
505
- throw new Error(`HyperFrames ${args[0]} failed: ${error.message}`);
506
- }
507
- return true;
508
- }
509
-
510
- function createHyperframesProgressParser(onProgress) {
511
- let pending = '';
512
- const parseLine = (line) => {
513
- const marker = line.indexOf('@hf-progress ');
514
- if (marker < 0) return;
515
- try {
516
- const progress = JSON.parse(line.slice(marker + '@hf-progress '.length));
517
- if (typeof progress.code !== 'string' || !Number.isFinite(progress.pct)) return;
518
- const event = {
519
- stage: 'capture',
520
- phase: progress.code,
521
- percent: Math.max(0, Math.min(100, progress.pct)),
522
- ...(Number.isFinite(progress.done) ? { completed: progress.done } : {}),
523
- ...(Number.isFinite(progress.total) ? { total: progress.total } : {}),
524
- };
525
- try {
526
- const result = onProgress(event);
527
- if (result && typeof result.then === 'function') {
528
- Promise.resolve(result).catch(warnProgressCallback);
529
- }
530
- } catch (error) {
531
- warnProgressCallback(error);
532
- }
533
- } catch {
534
- // Ignore malformed third-party progress lines; the renderer still reports its process result.
535
- }
536
- };
537
- return {
538
- push(chunk) {
539
- pending += chunk.toString('utf8');
540
- let newline;
541
- while ((newline = pending.indexOf('\n')) >= 0) {
542
- parseLine(pending.slice(0, newline).replace(/\r$/, ''));
543
- pending = pending.slice(newline + 1);
544
- }
545
- if (pending.length > 16 * 1024) pending = pending.slice(-16 * 1024);
546
- },
547
- flush() {
548
- if (pending) parseLine(pending);
549
- pending = '';
550
- },
551
- };
552
- }
553
-
554
- function warnProgressCallback(error) {
555
- const detail = error instanceof Error ? error.message : String(error);
556
- process.emitWarning(`Render progress callback failed: ${detail}`);
557
- }
558
-
559
- function processTranscript(project, inputPath, durationSeconds, command) {
560
- const metaPath = join(project, 'meta.json');
561
- const indexPath = join(project, 'index.html');
562
- const rawPath = join(project, 'transcript.json');
563
- const outputPath = join(project, 'transcripts', 'transcript.json');
564
- if (!existsSync(metaPath) || !existsSync(indexPath)) throw new Error(`Not a video project: ${project}`);
565
- if (existsSync(rawPath) || existsSync(outputPath)) {
566
- throw new Error('This project already has a transcript; create a new project to preserve the existing edit.');
567
- }
568
- const originalHtml = readFileSync(indexPath);
569
- try {
570
- runHyperframes(command ?? ['transcribe', inputPath, '--dir', project, '--json'], project);
571
- const raw = JSON.parse(readFileSync(rawPath, 'utf8'));
572
- const granularity = /\.(srt|vtt)$/i.test(inputPath) ? 'cue' : 'word';
573
- const transcript = normalizeTranscript(raw, durationSeconds, granularity);
574
- writeFileSync(outputPath, `${JSON.stringify(transcript, null, 2)}\n`, { flag: 'wx' });
575
- return { transcriptPath: outputPath, wordCount: transcript.words.length, durationSeconds };
576
- } catch (error) {
577
- writeFileSync(indexPath, originalHtml);
578
- rmSync(rawPath, { force: true });
579
- rmSync(outputPath, { force: true });
580
- throw error;
581
- }
582
- }
583
-
584
- function resolveHyperframesPackage() {
585
- try {
586
- return require.resolve('hyperframes/package.json');
587
- } catch {
588
- throw new Error('HyperFrames is not installed. Run npm install in the engine repository.');
589
- }
590
- }
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { closeSync, copyFileSync, cpSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { mkdir, mkdtemp, open, readdir, rename, rm } from 'node:fs/promises';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { basename, dirname, extname, join, resolve } from 'node:path';
8
+ import { probeMedia, probeMediaAsync } from '../media/probe.mjs';
9
+ import { createFfmpegProgressParser, emitRenderProgress } from './progress.mjs';
10
+ import { normalizeTranscript } from '../transcript/normalize.mjs';
11
+ import { parseSubtitleText } from '../transcript/subtitles.mjs';
12
+ import { frameRateNumber } from '../media/frame-rate.mjs';
13
+ import { resolveColorProfile, resolveEncoderArgs, resolveProfileEncoder, resolveRenderControlArgs, resolveRenderFormat, resolveVideoEncoder } from './formats.mjs';
14
+ import { hasFfmpegEncoder, hasFfmpegFilter } from '../media/ffmpeg-capabilities.mjs';
15
+ import { runProcess } from './process.mjs';
16
+ import { fingerprintRenderProject, prepareRenderCheckpoint, removeRenderCheckpoint, restoreRenderCheckpoint, saveRenderCheckpoint } from './checkpoints.mjs';
17
+
18
+ const require = createRequire(import.meta.url);
19
+
20
+ export function lintProject(projectPath) {
21
+ return runHyperframes(['lint'], projectPath);
22
+ }
23
+
24
+ export function previewProject(projectPath) {
25
+ return runHyperframes(['preview'], projectPath);
26
+ }
27
+
28
+ export function doctorHyperframes(projectPath = process.cwd()) {
29
+ return runHyperframes(['doctor'], projectPath);
30
+ }
31
+
32
+ export function importProjectTranscript(projectPath, mediaPath, transcriptPath) {
33
+ const project = resolve(projectPath);
34
+ const media = probeMedia(mediaPath, { requireVideo: false });
35
+ const inputPath = resolve(transcriptPath);
36
+ const inputExtension = extname(inputPath).toLowerCase();
37
+ if (!existsSync(join(project, 'meta.json')) || !existsSync(join(project, 'index.html'))) throw new Error(`Not a video project: ${project}`);
38
+ const rawPath = join(project, 'transcript.json');
39
+ const outputPath = join(project, 'transcripts', 'transcript.json');
40
+ if (existsSync(rawPath) || existsSync(outputPath)) {
41
+ throw new Error('This project already has a transcript; create a new project to preserve the existing edit.');
42
+ }
43
+ const granularity = ['.srt', '.vtt'].includes(inputExtension) ? 'cue' : 'word';
44
+ const raw = granularity === 'cue'
45
+ ? parseSubtitleText(readFileSync(inputPath, 'utf8'), inputExtension.slice(1))
46
+ : JSON.parse(readFileSync(inputPath, 'utf8'));
47
+ const transcript = normalizeTranscript(raw, media.durationSeconds, granularity);
48
+ writeFileSync(outputPath, `${JSON.stringify(transcript, null, 2)}\n`, { flag: 'wx' });
49
+ return { transcriptPath: outputPath, wordCount: transcript.words.length, durationSeconds: transcript.durationSeconds };
50
+ }
51
+
52
+ export function transcribeProject(projectPath, mediaPath, options = {}) {
53
+ const project = resolve(projectPath);
54
+ const media = probeMedia(mediaPath, { requireVideo: false });
55
+ const args = ['transcribe', resolve(mediaPath), '--dir', project, '--json'];
56
+ if (options.model) args.push('--model', options.model);
57
+ if (options.language) args.push('--language', options.language);
58
+ return processTranscript(project, resolve(mediaPath), media.durationSeconds, args);
59
+ }
60
+
61
+ export function renderProject(projectPath, outputPath, quality = 'draft', options = {}) {
62
+ if (options.resumeCheckpoint === true) throw new TypeError('Resumable checkpoints require renderProjectAsync.');
63
+ if (options.signal !== undefined || options.timeoutSeconds !== undefined) {
64
+ throw new TypeError('Cancellation and render timeouts require renderProjectAsync.');
65
+ }
66
+ if (options.onProgress !== undefined) throw new TypeError('Render progress callbacks require renderProjectAsync.');
67
+ const project = resolve(projectPath);
68
+ const indexPath = join(project, 'index.html');
69
+ const metaPath = join(project, 'meta.json');
70
+ if (!existsSync(indexPath) || !existsSync(metaPath)) {
71
+ throw new Error(`Not a video project: ${project}`);
72
+ }
73
+ if (!['draft', 'standard', 'high'].includes(quality)) {
74
+ throw new TypeError('Quality must be draft, standard, or high.');
75
+ }
76
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
77
+ const output = resolve(outputPath ?? join(project, `renders/draft.${options.format ?? 'mp4'}`));
78
+ const format = resolveRenderFormat(output, options.format);
79
+ const encoderSettings = options.encoder ?? {};
80
+ const videoEncoder = resolveVideoEncoder(format, encoderSettings);
81
+ const colorProfile = resolveColorProfile(options.colorProfile, format);
82
+ const finalEncoder = colorProfile ? resolveProfileEncoder(format, videoEncoder, colorProfile) : videoEncoder;
83
+ if (finalEncoder.codec) hasFfmpegEncoder(finalEncoder.codec);
84
+ if (colorProfile && !hasFfmpegFilter(colorProfile.requiredFilter)) throw new Error(`The ${colorProfile.id} color profile requires an FFmpeg build with the ${colorProfile.requiredFilter} filter.`);
85
+ const encoderArgs = resolveEncoderArgs(format, encoderSettings);
86
+ const renderControlArgs = resolveRenderControlArgs(options);
87
+ if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
88
+ const outputDir = dirname(output);
89
+ mkdirSync(outputDir, { recursive: true });
90
+ const pngSequence = format === 'png-sequence';
91
+ const temporaryDirectory = mkdtempSync(join(tmpdir(), 'aive-render-'));
92
+ const temporaryOutput = pngSequence
93
+ ? join(temporaryDirectory, 'frames')
94
+ : join(temporaryDirectory, `render.${format}`);
95
+ const encodedOutput = finalEncoder.codec
96
+ ? join(temporaryDirectory, `encoded.${format}`)
97
+ : undefined;
98
+ let outputCreated = false;
99
+
100
+ try {
101
+ runHyperframes(['render', '--format', format, '--fps', String(meta.fps), '--quality', quality, ...renderControlArgs, ...encoderArgs, '--output', temporaryOutput], project);
102
+ if (pngSequence) {
103
+ const frames = readdirSync(temporaryOutput, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.png'));
104
+ const expectedFrames = Math.round(meta.durationSeconds * frameRateNumber(meta.fps ?? 30));
105
+ if (frames.length !== expectedFrames || frames.length === 0) throw new Error(`PNG sequence has ${frames.length} frames; expected ${expectedFrames}.`);
106
+ for (const frame of frames) {
107
+ const data = readFileSync(join(temporaryOutput, frame.name));
108
+ if (data.length < 24 || data.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a'
109
+ || data.readUInt32BE(16) !== meta.width || data.readUInt32BE(20) !== meta.height) {
110
+ throw new Error(`PNG sequence frame ${frame.name} has an invalid signature or dimensions.`);
111
+ }
112
+ }
113
+ if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
114
+ publishPngSequenceSync(temporaryOutput, output, outputDir);
115
+ return { project, output, format, durationSeconds: meta.durationSeconds, width: meta.width, height: meta.height, frameRate: frameRateNumber(meta.fps), frameCount: frames.length };
116
+ }
117
+ if (finalEncoder.codec) encodeVideoFile(temporaryOutput, encodedOutput, format, finalEncoder, colorProfile);
118
+ const rendered = probeMedia(encodedOutput ?? temporaryOutput);
119
+ const expectedFps = frameRateNumber(meta.fps ?? 30);
120
+ if (Math.abs(rendered.durationSeconds - meta.durationSeconds) > Math.max(0.15, 1 / expectedFps)) {
121
+ throw new Error(`Rendered duration ${rendered.durationSeconds.toFixed(3)}s does not match project duration ${meta.durationSeconds}s.`);
122
+ }
123
+ if (rendered.width !== meta.width || rendered.height !== meta.height) {
124
+ throw new Error(`Rendered canvas ${rendered.width}×${rendered.height} does not match project canvas ${meta.width}×${meta.height}.`);
125
+ }
126
+ const renderedFrameRate = rendered.videoCodec === 'gif'
127
+ ? rendered.nominalFrameRate ?? rendered.frameRate
128
+ : rendered.frameRate;
129
+ if (renderedFrameRate && Math.abs(renderedFrameRate - expectedFps) > 0.001) {
130
+ throw new Error(`Rendered frame rate ${renderedFrameRate} fps does not match project frame rate ${meta.fps} fps.`);
131
+ }
132
+ const expectedCodec = { h264: 'h264', h265: 'hevc', vp9: 'vp9', prores: 'prores' }[finalEncoder.codecId];
133
+ if (expectedCodec && rendered.videoCodec !== expectedCodec) {
134
+ throw new Error(`Rendered video codec ${rendered.videoCodec} does not match requested codec ${finalEncoder.codecId}.`);
135
+ }
136
+ validateColorProfile(rendered, colorProfile);
137
+ closeSync(openSync(output, 'wx'));
138
+ outputCreated = true;
139
+ copyFileSync(encodedOutput ?? temporaryOutput, output);
140
+ return { project, output, format, durationSeconds: rendered.durationSeconds, width: rendered.width, height: rendered.height, frameRate: rendered.frameRate, videoCodec: rendered.videoCodec, ...(colorProfile ? { colorProfile: colorProfile.id } : {}) };
141
+ } catch (error) {
142
+ if (outputCreated && existsSync(output)) unlinkSync(output);
143
+ throw error;
144
+ } finally {
145
+ cleanupTemporaryRenderOutputs(outputDir, temporaryOutput, encodedOutput, temporaryDirectory);
146
+ }
147
+ }
148
+
149
+ export async function renderProjectAsync(projectPath, outputPath, quality = 'draft', options = {}) {
150
+ const project = resolve(projectPath);
151
+ const indexPath = join(project, 'index.html');
152
+ const metaPath = join(project, 'meta.json');
153
+ if (!existsSync(indexPath) || !existsSync(metaPath)) throw new Error(`Not a video project: ${project}`);
154
+ if (!['draft', 'standard', 'high'].includes(quality)) throw new TypeError('Quality must be draft, standard, or high.');
155
+
156
+ const { signal: externalSignal, onProgress, ...renderOptions } = options;
157
+ if (externalSignal !== undefined && (typeof externalSignal.aborted !== 'boolean' || typeof externalSignal.addEventListener !== 'function')) {
158
+ throw new TypeError('Render signal must be an AbortSignal.');
159
+ }
160
+ if (onProgress !== undefined && typeof onProgress !== 'function') throw new TypeError('Render onProgress must be a function.');
161
+ const controls = resolveRenderControlArgs(renderOptions);
162
+ const resumeCheckpoint = renderOptions.resumeCheckpoint === true;
163
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
164
+ const output = resolve(outputPath ?? join(project, `renders/draft.${renderOptions.format ?? 'mp4'}`));
165
+ const format = resolveRenderFormat(output, renderOptions.format);
166
+ const encoderSettings = renderOptions.encoder ?? {};
167
+ const videoEncoder = resolveVideoEncoder(format, encoderSettings);
168
+ const colorProfile = resolveColorProfile(renderOptions.colorProfile, format);
169
+ const finalEncoder = colorProfile ? resolveProfileEncoder(format, videoEncoder, colorProfile) : videoEncoder;
170
+ if (finalEncoder.codec) hasFfmpegEncoder(finalEncoder.codec);
171
+ if (colorProfile && !hasFfmpegFilter(colorProfile.requiredFilter)) throw new Error(`The ${colorProfile.id} color profile requires an FFmpeg build with the ${colorProfile.requiredFilter} filter.`);
172
+ const encoderArgs = resolveEncoderArgs(format, encoderSettings);
173
+ if (resumeCheckpoint && format === 'png-sequence') throw new TypeError('Resumable checkpoints do not support PNG sequence output.');
174
+ if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
175
+ const outputDir = dirname(output);
176
+ mkdirSync(outputDir, { recursive: true });
177
+ const pngSequence = format === 'png-sequence';
178
+ let temporaryDirectory;
179
+ let temporaryOutput;
180
+ let encodedOutput;
181
+ let checkpoint;
182
+ let checkpointArtifact;
183
+ let resumedFromCheckpoint = false;
184
+ if (resumeCheckpoint) {
185
+ const fingerprint = await fingerprintRenderProject(project, {
186
+ quality,
187
+ format,
188
+ fps: meta.fps,
189
+ width: meta.width,
190
+ height: meta.height,
191
+ durationSeconds: meta.durationSeconds,
192
+ encoder: encoderSettings,
193
+ colorProfile: colorProfile?.id,
194
+ workers: renderOptions.workers ?? 'auto',
195
+ lowMemoryMode: renderOptions.lowMemoryMode ?? process.env.PRODUCER_LOW_MEMORY_MODE ?? 'default',
196
+ platform: process.platform,
197
+ arch: process.arch,
198
+ node: process.version,
199
+ hyperframes: JSON.parse(readFileSync(resolveHyperframesPackage(), 'utf8')).version,
200
+ });
201
+ checkpoint = await prepareRenderCheckpoint(output, fingerprint, {
202
+ ...(renderOptions.checkpointMaxBytes !== undefined ? { maxBytes: renderOptions.checkpointMaxBytes } : {}),
203
+ ...(renderOptions.checkpointMaxAgeHours !== undefined ? { maxAgeHours: renderOptions.checkpointMaxAgeHours } : {}),
204
+ });
205
+ checkpointArtifact = await restoreRenderCheckpoint(checkpoint);
206
+ resumedFromCheckpoint = Boolean(checkpointArtifact);
207
+ }
208
+ let outputCreated = false;
209
+ let completed = false;
210
+ const control = createRenderControl(externalSignal, renderOptions.timeoutSeconds);
211
+
212
+ try {
213
+ temporaryDirectory = await mkdtemp(join(tmpdir(), 'aive-render-'));
214
+ temporaryOutput = pngSequence
215
+ ? join(temporaryDirectory, 'frames')
216
+ : join(temporaryDirectory, `render.${format}`);
217
+ encodedOutput = finalEncoder.codec
218
+ ? join(temporaryDirectory, `encoded.${format}`)
219
+ : undefined;
220
+ if (!checkpointArtifact) {
221
+ emitRenderProgress(onProgress, 'capture', 'started');
222
+ await runHyperframesAsync(['render', '--format', format, '--fps', String(meta.fps), '--quality', quality, ...controls, ...encoderArgs, '--output', temporaryOutput], project, control.signal, onProgress);
223
+ emitRenderProgress(onProgress, 'capture', 'completed');
224
+ if (checkpoint) {
225
+ emitRenderProgress(onProgress, 'validation', 'started', { target: 'checkpoint-capture' });
226
+ validateRenderedMedia(await probeMediaAsync(temporaryOutput, { signal: control.signal }), meta);
227
+ emitRenderProgress(onProgress, 'validation', 'completed', { target: 'checkpoint-capture' });
228
+ checkpointArtifact = await saveRenderCheckpoint(checkpoint, temporaryOutput);
229
+ }
230
+ } else {
231
+ emitRenderProgress(onProgress, 'capture', 'skipped', { reason: 'checkpoint-resume' });
232
+ }
233
+ if (pngSequence) {
234
+ emitRenderProgress(onProgress, 'validation', 'started', { target: 'png-sequence' });
235
+ const frames = readdirSync(temporaryOutput, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.png'));
236
+ const expectedFrames = Math.round(meta.durationSeconds * frameRateNumber(meta.fps ?? 30));
237
+ if (frames.length !== expectedFrames || frames.length === 0) throw new Error(`PNG sequence has ${frames.length} frames; expected ${expectedFrames}.`);
238
+ for (const frame of frames) {
239
+ control.signal.throwIfAborted();
240
+ if (!await hasExpectedPngHeader(join(temporaryOutput, frame.name), meta.width, meta.height)) {
241
+ throw new Error(`PNG sequence frame ${frame.name} has an invalid signature or dimensions.`);
242
+ }
243
+ }
244
+ emitRenderProgress(onProgress, 'validation', 'completed', { target: 'png-sequence', completed: frames.length, total: frames.length });
245
+ if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
246
+ control.signal.throwIfAborted();
247
+ emitRenderProgress(onProgress, 'publish', 'started');
248
+ await publishPngSequenceAsync(temporaryOutput, output, outputDir, control.signal);
249
+ outputCreated = true;
250
+ control.signal.throwIfAborted();
251
+ emitRenderProgress(onProgress, 'publish', 'completed');
252
+ return { project, output, format, durationSeconds: meta.durationSeconds, width: meta.width, height: meta.height, frameRate: frameRateNumber(meta.fps), frameCount: frames.length };
253
+ }
254
+ const renderArtifact = checkpointArtifact ?? temporaryOutput;
255
+ if (finalEncoder.codec) {
256
+ emitRenderProgress(onProgress, 'encoding', 'started', { codec: finalEncoder.codecId });
257
+ await encodeVideoFileAsync(renderArtifact, encodedOutput, format, finalEncoder, control.signal, colorProfile, meta.durationSeconds, onProgress);
258
+ emitRenderProgress(onProgress, 'encoding', 'completed', { codec: finalEncoder.codecId });
259
+ } else emitRenderProgress(onProgress, 'encoding', 'skipped', { reason: 'renderer-output-is-final' });
260
+ emitRenderProgress(onProgress, 'validation', 'started', { target: 'final-output' });
261
+ const rendered = await probeMediaAsync(encodedOutput ?? renderArtifact, { signal: control.signal });
262
+ validateRenderedMedia(rendered, meta, finalEncoder.codecId);
263
+ validateColorProfile(rendered, colorProfile);
264
+ emitRenderProgress(onProgress, 'validation', 'completed', { target: 'final-output' });
265
+ const outputFile = await open(output, 'wx');
266
+ outputCreated = true;
267
+ emitRenderProgress(onProgress, 'publish', 'started');
268
+ await pipeline(createReadStream(encodedOutput ?? renderArtifact, { signal: control.signal }),
269
+ outputFile.createWriteStream(), { signal: control.signal });
270
+ emitRenderProgress(onProgress, 'publish', 'completed');
271
+ completed = true;
272
+ return { project, output, format, durationSeconds: rendered.durationSeconds, width: rendered.width, height: rendered.height, frameRate: rendered.frameRate, videoCodec: rendered.videoCodec, ...(colorProfile ? { colorProfile: colorProfile.id } : {}), ...(resumeCheckpoint ? { resumedFromCheckpoint } : {}) };
273
+ } catch (error) {
274
+ if (outputCreated && existsSync(output)) await rm(output, { recursive: true, force: true });
275
+ if (checkpointArtifact) error.message += ' The completed capture is preserved for resume; retry the same render options to reuse it.';
276
+ throw error;
277
+ } finally {
278
+ if (temporaryOutput) await cleanupTemporaryRenderOutputsAsync(outputDir, temporaryOutput, encodedOutput, temporaryDirectory);
279
+ if (checkpoint && completed) {
280
+ await removeRenderCheckpoint(checkpoint).catch((error) => process.emitWarning(`Could not remove completed render checkpoint: ${error.message}`));
281
+ }
282
+ control.dispose();
283
+ }
284
+ }
285
+
286
+ async function hasExpectedPngHeader(path, width, height) {
287
+ const file = await open(path, 'r');
288
+ try {
289
+ const header = Buffer.alloc(24);
290
+ const { bytesRead } = await file.read(header, 0, header.length, 0);
291
+ return bytesRead === header.length && header.subarray(0, 8).toString('hex') === '89504e470d0a1a0a'
292
+ && header.readUInt32BE(16) === width && header.readUInt32BE(20) === height;
293
+ } finally {
294
+ await file.close();
295
+ }
296
+ }
297
+
298
+ async function cleanupTemporaryRenderOutputsAsync(outputDir, temporaryOutput, encodedOutput, temporaryDirectory) {
299
+ await rm(temporaryOutput, { recursive: true, force: true });
300
+ const outputName = basename(temporaryOutput);
301
+ const transactionStem = outputName.replace(/\.(?:mp4|webm|mov|gif)$/i, '');
302
+ const transactionPrefix = `.${transactionStem}.hf-transaction-`;
303
+ const transactionDirectory = temporaryDirectory ?? outputDir;
304
+ if (existsSync(transactionDirectory)) {
305
+ for (const entry of await readdir(transactionDirectory, { withFileTypes: true })) {
306
+ if (entry.isDirectory() && entry.name.startsWith(transactionPrefix)) {
307
+ await rm(join(transactionDirectory, entry.name), { recursive: true, force: true });
308
+ }
309
+ }
310
+ }
311
+ if (encodedOutput) await rm(encodedOutput, { force: true });
312
+ if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true });
313
+ }
314
+
315
+ function validateRenderedMedia(rendered, meta, codecId) {
316
+ const expectedFps = frameRateNumber(meta.fps ?? 30);
317
+ if (Math.abs(rendered.durationSeconds - meta.durationSeconds) > Math.max(0.15, 1 / expectedFps)) throw new Error(`Rendered duration ${rendered.durationSeconds.toFixed(3)}s does not match project duration ${meta.durationSeconds}s.`);
318
+ if (rendered.width !== meta.width || rendered.height !== meta.height) throw new Error(`Rendered canvas ${rendered.width}×${rendered.height} does not match project canvas ${meta.width}×${meta.height}.`);
319
+ const renderedFrameRate = rendered.videoCodec === 'gif'
320
+ ? rendered.nominalFrameRate ?? rendered.frameRate
321
+ : rendered.frameRate;
322
+ if (renderedFrameRate && Math.abs(renderedFrameRate - expectedFps) > 0.001) throw new Error(`Rendered frame rate ${renderedFrameRate} fps does not match project frame rate ${meta.fps} fps.`);
323
+ const expectedCodec = { h264: 'h264', h265: 'hevc', vp9: 'vp9', prores: 'prores' }[codecId];
324
+ if (expectedCodec && rendered.videoCodec !== expectedCodec) throw new Error(`Rendered video codec ${rendered.videoCodec} does not match requested codec ${codecId}.`);
325
+ }
326
+
327
+ function validateColorProfile(rendered, colorProfile) {
328
+ if (!colorProfile) return;
329
+ for (const field of ['colorSpace', 'colorTransfer', 'colorPrimaries', 'colorRange', ...(colorProfile.pixelFormat ? ['pixelFormat'] : [])]) {
330
+ if (rendered[field] !== colorProfile[field]) {
331
+ throw new Error(`Rendered ${field} ${rendered[field] ?? 'unknown'} does not match requested ${colorProfile.id} profile (${colorProfile[field]}).`);
332
+ }
333
+ }
334
+ }
335
+
336
+ function cleanupTemporaryRenderOutputs(outputDir, temporaryOutput, encodedOutput, temporaryDirectory) {
337
+ rmSync(temporaryOutput, { recursive: true, force: true });
338
+ const outputName = basename(temporaryOutput);
339
+ const transactionStem = outputName.replace(/\.(?:mp4|webm|mov|gif)$/i, '');
340
+ const transactionPrefix = `.${transactionStem}.hf-transaction-`;
341
+ const transactionDirectory = temporaryDirectory ?? outputDir;
342
+ if (existsSync(transactionDirectory)) {
343
+ for (const entry of readdirSync(transactionDirectory, { withFileTypes: true })) {
344
+ if (entry.isDirectory() && entry.name.startsWith(transactionPrefix)) {
345
+ rmSync(join(transactionDirectory, entry.name), { recursive: true, force: true });
346
+ }
347
+ }
348
+ }
349
+ if (encodedOutput) rmSync(encodedOutput, { force: true });
350
+ if (temporaryDirectory) rmSync(temporaryDirectory, { recursive: true, force: true });
351
+ }
352
+
353
+ function publishPngSequenceSync(temporaryOutput, output, outputDir) {
354
+ const stagingDirectory = mkdtempSync(join(outputDir, '.aive-sequence-'));
355
+ const stagingOutput = join(stagingDirectory, 'frames');
356
+ try {
357
+ cpSync(temporaryOutput, stagingOutput, { recursive: true, force: false, errorOnExist: true });
358
+ if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
359
+ renameSync(stagingOutput, output);
360
+ } finally {
361
+ rmSync(stagingDirectory, { recursive: true, force: true });
362
+ }
363
+ }
364
+
365
+ async function publishPngSequenceAsync(temporaryOutput, output, outputDir, signal) {
366
+ const stagingDirectory = await mkdtemp(join(outputDir, '.aive-sequence-'));
367
+ const stagingOutput = join(stagingDirectory, 'frames');
368
+ try {
369
+ await mkdir(stagingOutput);
370
+ for (const entry of await readdir(temporaryOutput, { withFileTypes: true })) {
371
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.png')) continue;
372
+ signal.throwIfAborted();
373
+ await pipeline(
374
+ createReadStream(join(temporaryOutput, entry.name), { signal }),
375
+ createWriteStream(join(stagingOutput, entry.name), { flags: 'wx' }),
376
+ { signal },
377
+ );
378
+ }
379
+ signal.throwIfAborted();
380
+ if (existsSync(output)) throw new Error(`Output already exists: ${output}`);
381
+ await rename(stagingOutput, output);
382
+ } finally {
383
+ await rm(stagingDirectory, { recursive: true, force: true });
384
+ }
385
+ }
386
+
387
+ function createRenderControl(externalSignal, timeoutSeconds) {
388
+ const controller = new AbortController();
389
+ const relayAbort = () => controller.abort(externalSignal.reason);
390
+ if (externalSignal?.aborted) relayAbort();
391
+ else externalSignal?.addEventListener('abort', relayAbort, { once: true });
392
+ const timer = timeoutSeconds === undefined ? undefined : setTimeout(() => {
393
+ const error = new Error(`Render exceeded the configured timeout of ${timeoutSeconds} seconds.`);
394
+ error.name = 'TimeoutError';
395
+ controller.abort(error);
396
+ }, timeoutSeconds * 1000);
397
+ timer?.unref();
398
+ return {
399
+ signal: controller.signal,
400
+ dispose() {
401
+ if (timer) clearTimeout(timer);
402
+ externalSignal?.removeEventListener('abort', relayAbort);
403
+ },
404
+ };
405
+ }
406
+
407
+ async function encodeVideoFileAsync(inputPath, outputPath, format, encoder, signal, colorProfile, durationSeconds, onProgress) {
408
+ const args = [
409
+ '-nostdin', '-hide_banner', '-v', 'error', '-y', '-i', inputPath,
410
+ '-map', '0:v:0', '-map', '0:a?', '-c:v', encoder.codec,
411
+ ];
412
+ appendVideoProfileArgs(args, encoder, colorProfile);
413
+ if (encoder.crf !== undefined) {
414
+ args.push('-crf', String(encoder.crf));
415
+ if (encoder.codecId === 'vp9') args.push('-b:v', '0');
416
+ }
417
+ if (encoder.videoBitrate !== undefined) args.push('-b:v', encoder.videoBitrate);
418
+ args.push('-c:a', 'copy', '-progress', 'pipe:1', '-nostats');
419
+ if (format === 'mp4' || format === 'mov') args.push('-movflags', '+faststart');
420
+ args.push(outputPath);
421
+ const progressParser = onProgress ? createFfmpegProgressParser(onProgress, 'encoding', durationSeconds, { codec: encoder.codecId }) : undefined;
422
+ await runProcess('ffmpeg', args, {
423
+ signal,
424
+ ...(progressParser ? {
425
+ stdio: 'capture',
426
+ onStdout: progressParser.push,
427
+ onStderr: (chunk) => process.stderr.write(chunk),
428
+ } : {}),
429
+ });
430
+ progressParser?.flush();
431
+ }
432
+
433
+ function encodeVideoFile(inputPath, outputPath, format, encoder, colorProfile) {
434
+ const args = [
435
+ '-nostdin', '-hide_banner', '-v', 'error', '-y', '-i', inputPath,
436
+ '-map', '0:v:0', '-map', '0:a?', '-c:v', encoder.codec,
437
+ ];
438
+ appendVideoProfileArgs(args, encoder, colorProfile);
439
+ if (encoder.crf !== undefined) {
440
+ args.push('-crf', String(encoder.crf));
441
+ if (encoder.codecId === 'vp9') args.push('-b:v', '0');
442
+ }
443
+ if (encoder.videoBitrate !== undefined) args.push('-b:v', encoder.videoBitrate);
444
+ args.push('-c:a', 'copy');
445
+ if (format === 'mp4' || format === 'mov') args.push('-movflags', '+faststart');
446
+ args.push(outputPath);
447
+ const result = spawnSync('ffmpeg', args, { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
448
+ if (result.error || result.status !== 0) {
449
+ throw new Error(`Could not encode ${encoder.codecId} video: ${result.error?.message ?? result.stderr.trim()}`);
450
+ }
451
+ }
452
+
453
+ function appendVideoProfileArgs(args, encoder, colorProfile) {
454
+ if (encoder.codecId === 'prores') args.push('-profile:v', '3', '-pix_fmt', 'yuv422p10le');
455
+ else args.push('-pix_fmt', colorProfile?.pixelFormat ?? 'yuv420p');
456
+ if (colorProfile) {
457
+ args.push('-vf', colorProfile.filter);
458
+ if (colorProfile.id === 'hdr10') {
459
+ args.push('-profile:v', 'main10', '-x265-params', `hdr10=1:master-display=${colorProfile.masteringDisplay}:max-cll=${colorProfile.contentLightLevel}`);
460
+ }
461
+ args.push('-color_primaries', colorProfile.colorPrimaries, '-color_trc', colorProfile.colorTransfer, '-colorspace', colorProfile.colorSpace, '-color_range', colorProfile.colorRange);
462
+ }
463
+ }
464
+
465
+ function runHyperframes(args, projectPath) {
466
+ const project = resolve(projectPath);
467
+ const packagePath = resolveHyperframesPackage();
468
+ const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
469
+ const bin = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.hyperframes;
470
+ if (!bin) throw new Error('Installed HyperFrames package has no CLI entry point.');
471
+ const cliPath = resolve(dirname(packagePath), bin);
472
+ const result = spawnSync(process.execPath, [cliPath, ...args], {
473
+ cwd: project,
474
+ stdio: 'inherit',
475
+ windowsHide: false,
476
+ });
477
+ if (result.error || result.status !== 0) {
478
+ throw new Error(`HyperFrames ${args[0]} failed: ${result.error?.message ?? `exit code ${result.status}`}`);
479
+ }
480
+ return true;
481
+ }
482
+
483
+ async function runHyperframesAsync(args, projectPath, signal, onProgress) {
484
+ const project = resolve(projectPath);
485
+ const packagePath = resolveHyperframesPackage();
486
+ const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
487
+ const bin = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.hyperframes;
488
+ if (!bin) throw new Error('Installed HyperFrames package has no CLI entry point.');
489
+ const cliPath = resolve(dirname(packagePath), bin);
490
+ const progressParser = onProgress ? createHyperframesProgressParser(onProgress) : undefined;
491
+ try {
492
+ await runProcess(process.execPath, [cliPath, ...args], {
493
+ cwd: project,
494
+ signal,
495
+ windowsHide: false,
496
+ ...(progressParser ? {
497
+ stdio: 'capture',
498
+ onStdout: progressParser.push,
499
+ onStderr: (chunk) => process.stderr.write(chunk),
500
+ } : {}),
501
+ });
502
+ progressParser?.flush();
503
+ } catch (error) {
504
+ if (error.name === 'AbortError' || error.name === 'TimeoutError') throw error;
505
+ throw new Error(`HyperFrames ${args[0]} failed: ${error.message}`);
506
+ }
507
+ return true;
508
+ }
509
+
510
+ function createHyperframesProgressParser(onProgress) {
511
+ let pending = '';
512
+ const parseLine = (line) => {
513
+ const marker = line.indexOf('@hf-progress ');
514
+ if (marker < 0) return;
515
+ try {
516
+ const progress = JSON.parse(line.slice(marker + '@hf-progress '.length));
517
+ if (typeof progress.code !== 'string' || !Number.isFinite(progress.pct)) return;
518
+ const event = {
519
+ stage: 'capture',
520
+ phase: progress.code,
521
+ percent: Math.max(0, Math.min(100, progress.pct)),
522
+ ...(Number.isFinite(progress.done) ? { completed: progress.done } : {}),
523
+ ...(Number.isFinite(progress.total) ? { total: progress.total } : {}),
524
+ };
525
+ try {
526
+ const result = onProgress(event);
527
+ if (result && typeof result.then === 'function') {
528
+ Promise.resolve(result).catch(warnProgressCallback);
529
+ }
530
+ } catch (error) {
531
+ warnProgressCallback(error);
532
+ }
533
+ } catch {
534
+ // Ignore malformed third-party progress lines; the renderer still reports its process result.
535
+ }
536
+ };
537
+ return {
538
+ push(chunk) {
539
+ pending += chunk.toString('utf8');
540
+ let newline;
541
+ while ((newline = pending.indexOf('\n')) >= 0) {
542
+ parseLine(pending.slice(0, newline).replace(/\r$/, ''));
543
+ pending = pending.slice(newline + 1);
544
+ }
545
+ if (pending.length > 16 * 1024) pending = pending.slice(-16 * 1024);
546
+ },
547
+ flush() {
548
+ if (pending) parseLine(pending);
549
+ pending = '';
550
+ },
551
+ };
552
+ }
553
+
554
+ function warnProgressCallback(error) {
555
+ const detail = error instanceof Error ? error.message : String(error);
556
+ process.emitWarning(`Render progress callback failed: ${detail}`);
557
+ }
558
+
559
+ function processTranscript(project, inputPath, durationSeconds, command) {
560
+ const metaPath = join(project, 'meta.json');
561
+ const indexPath = join(project, 'index.html');
562
+ const rawPath = join(project, 'transcript.json');
563
+ const outputPath = join(project, 'transcripts', 'transcript.json');
564
+ if (!existsSync(metaPath) || !existsSync(indexPath)) throw new Error(`Not a video project: ${project}`);
565
+ if (existsSync(rawPath) || existsSync(outputPath)) {
566
+ throw new Error('This project already has a transcript; create a new project to preserve the existing edit.');
567
+ }
568
+ const originalHtml = readFileSync(indexPath);
569
+ try {
570
+ runHyperframes(command ?? ['transcribe', inputPath, '--dir', project, '--json'], project);
571
+ const raw = JSON.parse(readFileSync(rawPath, 'utf8'));
572
+ const granularity = /\.(srt|vtt)$/i.test(inputPath) ? 'cue' : 'word';
573
+ const transcript = normalizeTranscript(raw, durationSeconds, granularity);
574
+ writeFileSync(outputPath, `${JSON.stringify(transcript, null, 2)}\n`, { flag: 'wx' });
575
+ return { transcriptPath: outputPath, wordCount: transcript.words.length, durationSeconds };
576
+ } catch (error) {
577
+ writeFileSync(indexPath, originalHtml);
578
+ rmSync(rawPath, { force: true });
579
+ rmSync(outputPath, { force: true });
580
+ throw error;
581
+ }
582
+ }
583
+
584
+ function resolveHyperframesPackage() {
585
+ try {
586
+ return require.resolve('hyperframes/package.json');
587
+ } catch {
588
+ throw new Error('HyperFrames is not installed. Run npm install in the engine repository.');
589
+ }
590
+ }