frameyard 0.5.0 → 0.6.0

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 (96) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/GLOSSARY.md +152 -24
  3. package/README.md +94 -29
  4. package/dist/cli.js +111 -14
  5. package/dist/cli.js.map +1 -1
  6. package/dist/contracts.d.ts +12 -0
  7. package/dist/contracts.d.ts.map +1 -1
  8. package/dist/contracts.js.map +1 -1
  9. package/dist/errors.d.ts +1 -1
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js.map +1 -1
  12. package/dist/index.d.ts +2 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/preview/main.js +89 -11
  17. package/dist/preview/main.js.map +1 -1
  18. package/dist/preview/timeline-data.d.ts +85 -0
  19. package/dist/preview/timeline-data.d.ts.map +1 -0
  20. package/dist/preview/timeline-data.js +2 -0
  21. package/dist/preview/timeline-data.js.map +1 -0
  22. package/dist/preview/timeline-host.d.ts +19 -0
  23. package/dist/preview/timeline-host.d.ts.map +1 -0
  24. package/dist/preview/timeline-host.js +297 -0
  25. package/dist/preview/timeline-host.js.map +1 -0
  26. package/dist/preview/timeline-main.d.ts +17 -0
  27. package/dist/preview/timeline-main.d.ts.map +1 -0
  28. package/dist/preview/timeline-main.js +364 -0
  29. package/dist/preview/timeline-main.js.map +1 -0
  30. package/dist/project.d.ts +16 -1
  31. package/dist/project.d.ts.map +1 -1
  32. package/dist/project.js +81 -26
  33. package/dist/project.js.map +1 -1
  34. package/dist/renderer/browser.d.ts +2 -0
  35. package/dist/renderer/browser.d.ts.map +1 -1
  36. package/dist/renderer/browser.js +6 -1
  37. package/dist/renderer/browser.js.map +1 -1
  38. package/dist/renderer/ffmpeg.d.ts +61 -0
  39. package/dist/renderer/ffmpeg.d.ts.map +1 -1
  40. package/dist/renderer/ffmpeg.js +198 -0
  41. package/dist/renderer/ffmpeg.js.map +1 -1
  42. package/dist/renderer/timeline.d.ts +109 -0
  43. package/dist/renderer/timeline.d.ts.map +1 -0
  44. package/dist/renderer/timeline.js +984 -0
  45. package/dist/renderer/timeline.js.map +1 -0
  46. package/dist/scaffold.d.ts.map +1 -1
  47. package/dist/scaffold.js +89 -24
  48. package/dist/scaffold.js.map +1 -1
  49. package/dist/server/preview-html.d.ts.map +1 -1
  50. package/dist/server/preview-html.js +15 -1
  51. package/dist/server/preview-html.js.map +1 -1
  52. package/dist/server/server.d.ts.map +1 -1
  53. package/dist/server/server.js +39 -1
  54. package/dist/server/server.js.map +1 -1
  55. package/dist/server/timeline-preview-data.d.ts +4 -0
  56. package/dist/server/timeline-preview-data.d.ts.map +1 -0
  57. package/dist/server/timeline-preview-data.js +97 -0
  58. package/dist/server/timeline-preview-data.js.map +1 -0
  59. package/dist/server/timeline-preview-html.d.ts +4 -0
  60. package/dist/server/timeline-preview-html.d.ts.map +1 -0
  61. package/dist/server/timeline-preview-html.js +227 -0
  62. package/dist/server/timeline-preview-html.js.map +1 -0
  63. package/dist/timeline/plan.d.ts +32 -0
  64. package/dist/timeline/plan.d.ts.map +1 -0
  65. package/dist/timeline/plan.js +68 -0
  66. package/dist/timeline/plan.js.map +1 -0
  67. package/dist/timeline/resolve.d.ts +60 -0
  68. package/dist/timeline/resolve.d.ts.map +1 -0
  69. package/dist/timeline/resolve.js +523 -0
  70. package/dist/timeline/resolve.js.map +1 -0
  71. package/dist/timeline/schema.d.ts +71 -0
  72. package/dist/timeline/schema.d.ts.map +1 -0
  73. package/dist/timeline/schema.js +425 -0
  74. package/dist/timeline/schema.js.map +1 -0
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/docs/adr/0002-make-frame-and-props-the-only-animation-state.md +2 -0
  78. package/docs/adr/0005-keep-parallel-rendering-opt-in-and-frame-ordered.md +2 -0
  79. package/docs/adr/0007-make-html-documents-the-composition-unit.md +1 -1
  80. package/docs/adr/0009-composite-production-frames-in-software.md +1 -1
  81. package/docs/adr/0010-make-timelines-the-render-unit.md +24 -0
  82. package/docs/adr/0011-compose-timeline-frames-in-a-live-browser-host.md +22 -0
  83. package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +31 -0
  84. package/docs/v0.1-design.md +135 -86
  85. package/examples/demo/README.md +10 -3
  86. package/examples/demo/timelines/demo.timeline.json +12 -0
  87. package/examples/demo/video.config.js +4 -0
  88. package/examples/video/README.md +50 -6
  89. package/examples/video/compositions/video-overlay.html +350 -0
  90. package/examples/video/props/video-timeline.json +10 -0
  91. package/examples/video/timelines/video-timeline-mp4.timeline.json +44 -0
  92. package/examples/video/timelines/video-timeline.timeline.json +44 -0
  93. package/examples/video/video.config.js +6 -0
  94. package/package.json +1 -1
  95. package/skills/frameyard/SKILL.md +17 -13
  96. package/skills/frameyard/references/timelines.md +141 -0
@@ -0,0 +1,984 @@
1
+ import { link, mkdir, mkdtemp, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { FrameyardError } from "../errors.js";
6
+ import { validateHtmlComposition } from "../html/validate.js";
7
+ import { startPreviewServer } from "../server/server.js";
8
+ import { inspectTimelineAudio } from "./audio.js";
9
+ import { openCompositionSession } from "./browser.js";
10
+ import { assertFfmpegCapabilities, compositeTimelineFrame, compositeTimelineSegment, concatTimelineSegments, encodePngSequenceToFfv1, mixAudioTimeline, muxVideoWithAudio, } from "./ffmpeg.js";
11
+ import { inspectTranscriptClips } from "./transcripts.js";
12
+ import { planTimelineRender } from "../timeline/plan.js";
13
+ import { resolveProjectTimeline, } from "../timeline/resolve.js";
14
+ export const MAX_TIMELINE_WORKERS = 16;
15
+ function validateWorkers(value) {
16
+ const workers = value ?? 8;
17
+ if (!Number.isInteger(workers) || workers < 1) {
18
+ throw new FrameyardError("USAGE", `--workers must be a positive integer; received ${String(value)}.`);
19
+ }
20
+ if (workers > MAX_TIMELINE_WORKERS) {
21
+ throw new FrameyardError("USAGE", `--workers must be at most ${MAX_TIMELINE_WORKERS}; received ${workers}.`);
22
+ }
23
+ return workers;
24
+ }
25
+ function throwIfAborted(signal) {
26
+ if (signal?.aborted) {
27
+ throw new FrameyardError("RENDER_FAILED", "Operation cancelled.");
28
+ }
29
+ }
30
+ async function pathExists(filePath) {
31
+ try {
32
+ await stat(filePath);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ async function ensureOutputAvailable(outPath, overwrite) {
40
+ if (!overwrite && (await pathExists(outPath))) {
41
+ throw new FrameyardError("OUTPUT_EXISTS", `Output already exists: ${outPath}. Pass --overwrite to replace it.`);
42
+ }
43
+ await mkdir(path.dirname(outPath), { recursive: true });
44
+ }
45
+ async function publishFile(tempPath, outPath, overwrite) {
46
+ if (overwrite) {
47
+ await rename(tempPath, outPath);
48
+ return;
49
+ }
50
+ try {
51
+ await link(tempPath, outPath);
52
+ }
53
+ catch (error) {
54
+ if (error.code === "EEXIST") {
55
+ throw new FrameyardError("OUTPUT_EXISTS", `Output already exists: ${outPath}. Pass --overwrite to replace it.`);
56
+ }
57
+ throw new FrameyardError("RENDER_FAILED", `Could not publish output ${outPath}: ${error.message}`, {
58
+ cause: error,
59
+ });
60
+ }
61
+ finally {
62
+ await rm(tempPath, { force: true });
63
+ }
64
+ }
65
+ function diagnosticKey(diagnostic) {
66
+ return JSON.stringify([diagnostic.severity, diagnostic.code, diagnostic.message, diagnostic.details ?? null]);
67
+ }
68
+ function mergeDiagnostics(...groups) {
69
+ const seen = new Set();
70
+ const result = [];
71
+ for (const group of groups) {
72
+ for (const diagnostic of group) {
73
+ const key = diagnosticKey(diagnostic);
74
+ if (!seen.has(key)) {
75
+ seen.add(key);
76
+ result.push(diagnostic);
77
+ }
78
+ }
79
+ }
80
+ return result;
81
+ }
82
+ function compositionDiagnostic(diagnostic, timelineId, clip, compositionId) {
83
+ return {
84
+ ...diagnostic,
85
+ details: {
86
+ ...diagnostic.details,
87
+ timeline: timelineId,
88
+ clip: clip.id,
89
+ track: clip.track,
90
+ composition: compositionId,
91
+ },
92
+ };
93
+ }
94
+ function clampVolume(value) {
95
+ return Math.min(1, Math.max(0, value));
96
+ }
97
+ function visualSourceSize(clip, timeline) {
98
+ const source = clip.source;
99
+ if (source.kind === "composition") {
100
+ return { width: source.metadata.width, height: source.metadata.height };
101
+ }
102
+ if (source.kind === "video" || source.kind === "image") {
103
+ if (source.width === undefined || source.height === undefined) {
104
+ throw new FrameyardError("TIMELINE_MEDIA_INVALID", `Media ${source.src} is missing width/height metadata for clip "${clip.id}".`);
105
+ }
106
+ return { width: source.width, height: source.height };
107
+ }
108
+ return { width: timeline.format.width, height: timeline.format.height };
109
+ }
110
+ function mapCompositionAudio(clip, audio, fps, window) {
111
+ if (window.muted) {
112
+ return null;
113
+ }
114
+ const clipOuterStart = clip.start + window.frameOffset;
115
+ const clipOuterEnd = clip.end + window.frameOffset;
116
+ const windowStart = Math.max(window.startFrame, clipOuterStart);
117
+ const windowEnd = Math.min(window.endFrame, clipOuterEnd);
118
+ if (windowEnd <= windowStart) {
119
+ return null;
120
+ }
121
+ const sourceAtWindowStart = clip.sourceIn + (windowStart - clipOuterStart);
122
+ const sourceAtWindowEnd = clip.sourceIn + (windowEnd - clipOuterStart);
123
+ const audioDurationFrames = audio.trimDuration !== undefined ? audio.trimDuration * fps : Number.POSITIVE_INFINITY;
124
+ const effectiveStart = Math.max(audio.startFrame, sourceAtWindowStart, clip.sourceIn);
125
+ const effectiveEnd = Math.min(sourceAtWindowEnd, clip.sourceIn + clip.duration, audio.startFrame + audioDurationFrames);
126
+ if (effectiveEnd <= effectiveStart) {
127
+ return null;
128
+ }
129
+ const duration = (effectiveEnd - effectiveStart) / fps;
130
+ if (duration <= 0) {
131
+ return null;
132
+ }
133
+ return {
134
+ src: audio.src,
135
+ startFrame: clip.start + (effectiveStart - clip.sourceIn) + window.frameOffset,
136
+ trimStart: audio.trimStart + (effectiveStart - audio.startFrame) / fps,
137
+ trimDuration: duration,
138
+ volume: clampVolume(audio.volume * window.gain),
139
+ };
140
+ }
141
+ function collectTimelineAudio(timeline, window, clips, transcripts) {
142
+ const fps = timeline.format.fps;
143
+ const trackById = new Map(timeline.tracks.map((track) => [track.id, track]));
144
+ for (const clip of timeline.clips) {
145
+ const source = clip.source;
146
+ const mappedStart = clip.start + window.frameOffset;
147
+ const mappedEnd = clip.end + window.frameOffset;
148
+ const startFrame = Math.max(mappedStart, window.startFrame);
149
+ const endFrame = Math.min(mappedEnd, window.endFrame);
150
+ if (endFrame <= startFrame) {
151
+ continue;
152
+ }
153
+ const clipGain = window.gain * (clip.audio?.gain ?? 1);
154
+ const clipMuted = window.muted || (clip.audio?.muted ?? false);
155
+ if (source.kind === "composition") {
156
+ for (const transcript of source.transcriptClips) {
157
+ transcripts.set(`${transcript.src}\u0000${transcript.words}`, transcript);
158
+ }
159
+ if (clipMuted) {
160
+ continue;
161
+ }
162
+ for (const audio of source.audioClips) {
163
+ const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain });
164
+ if (mapped) {
165
+ clips.push(mapped);
166
+ }
167
+ }
168
+ for (const audio of source.embeddedAudioClips) {
169
+ const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain });
170
+ if (mapped) {
171
+ clips.push({ ...mapped, allowSilent: true });
172
+ }
173
+ }
174
+ continue;
175
+ }
176
+ if (source.kind === "timeline") {
177
+ collectTimelineAudio(source.timeline, {
178
+ frameOffset: window.frameOffset + clip.start - clip.sourceIn,
179
+ startFrame,
180
+ endFrame,
181
+ gain: clipGain,
182
+ muted: clipMuted,
183
+ }, clips, transcripts);
184
+ continue;
185
+ }
186
+ if (source.kind === "video") {
187
+ if (source.hasAudio && !clipMuted) {
188
+ clips.push({
189
+ src: source.src,
190
+ startFrame,
191
+ trimStart: clip.sourceIn / fps + (startFrame - mappedStart) / fps,
192
+ trimDuration: (endFrame - startFrame) / fps,
193
+ volume: clampVolume(clipGain),
194
+ });
195
+ }
196
+ continue;
197
+ }
198
+ if (source.kind === "audio") {
199
+ const track = trackById.get(clip.track);
200
+ if (!track || track.kind !== "audio" || track.muted || clipMuted) {
201
+ continue;
202
+ }
203
+ clips.push({
204
+ src: source.src,
205
+ startFrame,
206
+ trimStart: clip.sourceIn / fps + (startFrame - mappedStart) / fps,
207
+ trimDuration: (endFrame - startFrame) / fps,
208
+ volume: clampVolume(clipGain * (track.gain ?? 1)),
209
+ });
210
+ }
211
+ }
212
+ }
213
+ function buildAudioClips(timeline) {
214
+ const clips = [];
215
+ const transcripts = new Map();
216
+ collectTimelineAudio(timeline, { frameOffset: 0, startFrame: 0, endFrame: timeline.durationInFrames, gain: 1, muted: false }, clips, transcripts);
217
+ return { clips, transcripts: [...transcripts.values()] };
218
+ }
219
+ /** Resolved timeline audio for preview surfaces. */
220
+ export function resolveTimelineAudio(timeline) {
221
+ const { clips, transcripts } = buildAudioClips(timeline);
222
+ return {
223
+ clips: clips.map((clip) => {
224
+ const { allowSilent: _allowSilent, ...resolved } = clip;
225
+ return resolved;
226
+ }),
227
+ transcripts,
228
+ };
229
+ }
230
+ async function captureCompositionLayer(options) {
231
+ const { project, timeline, clip, source, segment, layerIndex, serverUrl, segmentDir, infoCache } = options;
232
+ const propsKey = JSON.stringify(clip.props ?? null);
233
+ const cacheKey = `${source.entry}\u0000${propsKey}`;
234
+ let validation = infoCache.get(cacheKey);
235
+ if (!validation) {
236
+ validation = await validateHtmlComposition({
237
+ projectRoot: project.root,
238
+ entry: source.entry,
239
+ ...(clip.props !== undefined ? { props: clip.props } : {}),
240
+ });
241
+ infoCache.set(cacheKey, validation);
242
+ }
243
+ if (!validation.info) {
244
+ throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${clip.id}".`, { diagnostics: validation.diagnostics });
245
+ }
246
+ const layer = segment.layers[layerIndex];
247
+ if (!layer) {
248
+ throw new FrameyardError("INTERNAL", `Missing layer ${layerIndex} for segment ${segment.index}.`);
249
+ }
250
+ const prefix = `comp-${layerIndex}`;
251
+ const session = await openCompositionSession({
252
+ serverUrl,
253
+ entry: source.entry,
254
+ props: clip.props,
255
+ info: validation.info,
256
+ staticDiagnostics: validation.diagnostics,
257
+ transparentBackground: true,
258
+ });
259
+ const diagnostics = [];
260
+ try {
261
+ for (let offset = 0; offset < segment.frames; offset += 1) {
262
+ throwIfAborted(options.signal);
263
+ await session.seek(layer.sourceStart + offset);
264
+ const png = await session.capture();
265
+ await writeFile(path.join(segmentDir, `${prefix}-${String(offset).padStart(4, "0")}.png`), png);
266
+ }
267
+ for (const diagnostic of await session.readDiagnostics()) {
268
+ diagnostics.push(compositionDiagnostic(diagnostic, timeline.id, clip, source.id));
269
+ }
270
+ }
271
+ finally {
272
+ await session.close();
273
+ }
274
+ const outputPath = path.join(segmentDir, `${prefix}.mkv`);
275
+ await encodePngSequenceToFfv1({
276
+ pattern: path.join(segmentDir, `${prefix}-%04d.png`),
277
+ outputPath,
278
+ fps: timeline.format.fps,
279
+ signal: options.signal,
280
+ });
281
+ return { path: outputPath, diagnostics };
282
+ }
283
+ async function renderNestedTimelineRange(options) {
284
+ if (options.depth > 32) {
285
+ throw new FrameyardError("TIMELINE_INVALID", "Nested timeline depth exceeds 32.");
286
+ }
287
+ const plan = planTimelineRender(options.timeline, {
288
+ startFrame: options.startFrame,
289
+ endFrame: options.startFrame + options.frames,
290
+ });
291
+ const unique = randomUUID();
292
+ const prefix = `nested-${options.depth}-${options.startFrame}-${unique}`;
293
+ const results = [];
294
+ for (const segment of plan.segments) {
295
+ results.push(await processTimelineSegment({
296
+ project: options.project,
297
+ timeline: options.timeline,
298
+ segment,
299
+ serverUrl: options.serverUrl,
300
+ scratchRoot: options.scratchRoot,
301
+ infoCache: options.infoCache,
302
+ codec: "ffv1",
303
+ background: options.timeline.background ?? "transparent",
304
+ prefix,
305
+ depth: options.depth,
306
+ ...(options.signal ? { signal: options.signal } : {}),
307
+ }));
308
+ }
309
+ const diagnostics = results.flatMap((result) => result.diagnostics);
310
+ if (results.length === 1) {
311
+ return { path: results[0].path, diagnostics };
312
+ }
313
+ const outputPath = path.join(options.scratchRoot, `${prefix}.mkv`);
314
+ await concatTimelineSegments({
315
+ segmentPaths: results.map((result) => result.path),
316
+ listPath: path.join(options.scratchRoot, `${prefix}-segments.txt`),
317
+ outputPath,
318
+ ...(options.signal ? { signal: options.signal } : {}),
319
+ });
320
+ return { path: outputPath, diagnostics };
321
+ }
322
+ async function processTimelineSegment(options) {
323
+ const { project, timeline, segment, serverUrl, scratchRoot, infoCache } = options;
324
+ const codec = options.codec ?? "h264";
325
+ const background = options.background ?? (codec === "ffv1" ? "transparent" : (timeline.background ?? "black"));
326
+ const prefix = options.prefix ?? "timeline";
327
+ const depth = options.depth ?? 0;
328
+ const segmentDir = path.join(scratchRoot, `${prefix}-seg-${String(segment.index).padStart(4, "0")}`);
329
+ await mkdir(segmentDir, { recursive: true });
330
+ const diagnostics = [];
331
+ const layers = [];
332
+ for (const [layerIndex, layer] of segment.layers.entries()) {
333
+ throwIfAborted(options.signal);
334
+ const clip = layer.clip;
335
+ const source = clip.source;
336
+ const transform = clip.transform ?? {
337
+ fit: "contain",
338
+ x: 0,
339
+ y: 0,
340
+ scale: 1,
341
+ rotation: 0,
342
+ opacity: 1,
343
+ };
344
+ const base = {
345
+ fit: transform.fit,
346
+ x: transform.x,
347
+ y: transform.y,
348
+ ...(transform.width !== undefined && transform.height !== undefined
349
+ ? { width: transform.width, height: transform.height }
350
+ : {}),
351
+ scale: transform.scale,
352
+ rotation: transform.rotation,
353
+ opacity: transform.opacity,
354
+ };
355
+ if (source.kind === "composition") {
356
+ const captured = await captureCompositionLayer({
357
+ project,
358
+ timeline,
359
+ clip,
360
+ source,
361
+ segment,
362
+ layerIndex,
363
+ serverUrl,
364
+ segmentDir,
365
+ infoCache,
366
+ ...(options.signal ? { signal: options.signal } : {}),
367
+ });
368
+ diagnostics.push(...captured.diagnostics);
369
+ layers.push({
370
+ kind: "composition",
371
+ path: captured.path,
372
+ sourceWidth: source.metadata.width,
373
+ sourceHeight: source.metadata.height,
374
+ ...base,
375
+ });
376
+ continue;
377
+ }
378
+ if (source.kind === "video" || source.kind === "image") {
379
+ const size = visualSourceSize(clip, timeline);
380
+ layers.push({
381
+ kind: source.kind,
382
+ path: source.absolutePath,
383
+ sourceWidth: size.width,
384
+ sourceHeight: size.height,
385
+ ...base,
386
+ ...(source.kind === "video" ? { sourceStartFrame: layer.sourceStart } : {}),
387
+ });
388
+ continue;
389
+ }
390
+ if (source.kind === "timeline") {
391
+ const nested = await renderNestedTimelineRange({
392
+ project,
393
+ timeline: source.timeline,
394
+ startFrame: layer.sourceStart,
395
+ frames: segment.frames,
396
+ serverUrl,
397
+ scratchRoot,
398
+ infoCache,
399
+ depth: depth + 1,
400
+ ...(options.signal ? { signal: options.signal } : {}),
401
+ });
402
+ diagnostics.push(...nested.diagnostics);
403
+ layers.push({
404
+ kind: "timeline",
405
+ path: nested.path,
406
+ sourceWidth: source.timeline.format.width,
407
+ sourceHeight: source.timeline.format.height,
408
+ ...base,
409
+ });
410
+ continue;
411
+ }
412
+ throw new FrameyardError("TIMELINE_INVALID", `Unsupported visual source for clip "${clip.id}".`);
413
+ }
414
+ const extension = codec === "ffv1" ? "mkv" : "mp4";
415
+ const segmentPath = path.join(scratchRoot, `${prefix}-segment-${String(segment.index).padStart(4, "0")}.${extension}`);
416
+ await compositeTimelineSegment({
417
+ outputPath: segmentPath,
418
+ width: timeline.format.width,
419
+ height: timeline.format.height,
420
+ fps: timeline.format.fps,
421
+ frames: segment.frames,
422
+ background,
423
+ layers,
424
+ codec,
425
+ ...(options.signal ? { signal: options.signal } : {}),
426
+ });
427
+ await rm(segmentDir, { recursive: true, force: true });
428
+ return { path: segmentPath, diagnostics };
429
+ }
430
+ async function checkResolvedTimelineRange(options) {
431
+ if (options.context.depth > 32) {
432
+ throw new FrameyardError("TIMELINE_INVALID", "Nested timeline depth exceeds 32.");
433
+ }
434
+ const { timeline, startFrame, endFrame, context } = options;
435
+ const plan = planTimelineRender(timeline, { startFrame, endFrame });
436
+ const diagnostics = [];
437
+ const targets = new Map();
438
+ for (const segment of plan.segments) {
439
+ for (const layer of segment.layers) {
440
+ const source = layer.clip.source;
441
+ if (source.kind !== "composition") {
442
+ continue;
443
+ }
444
+ const key = `${source.entry}\u0000${JSON.stringify(layer.clip.props ?? null)}`;
445
+ if (!targets.has(key)) {
446
+ const validation = await validateHtmlComposition({
447
+ projectRoot: context.project.root,
448
+ entry: source.entry,
449
+ ...(layer.clip.props !== undefined ? { props: layer.clip.props } : {}),
450
+ });
451
+ if (!validation.info) {
452
+ throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${layer.clip.id}".`, { diagnostics: validation.diagnostics });
453
+ }
454
+ targets.set(key, { source, clip: layer.clip, validation });
455
+ }
456
+ }
457
+ }
458
+ const sessions = new Map();
459
+ try {
460
+ for (const [key, target] of targets) {
461
+ const session = await openCompositionSession({
462
+ serverUrl: context.serverUrl,
463
+ entry: target.source.entry,
464
+ props: target.clip.props,
465
+ info: target.validation.info,
466
+ staticDiagnostics: target.validation.diagnostics,
467
+ });
468
+ sessions.set(key, session);
469
+ }
470
+ for (const segment of plan.segments) {
471
+ for (let frame = segment.start; frame < segment.end; frame += 1) {
472
+ throwIfAborted(context.signal);
473
+ for (const layer of segment.layers) {
474
+ if (layer.clip.source.kind !== "composition") {
475
+ continue;
476
+ }
477
+ const source = layer.clip.source;
478
+ const key = `${source.entry}\u0000${JSON.stringify(layer.clip.props ?? null)}`;
479
+ const session = sessions.get(key);
480
+ if (!session) {
481
+ throw new FrameyardError("INTERNAL", `Missing composition session for clip "${layer.clip.id}".`);
482
+ }
483
+ await session.seek(layer.sourceStart + (frame - segment.start));
484
+ }
485
+ }
486
+ if (context.depth === 0) {
487
+ context.onProgress?.({
488
+ frame: segment.end,
489
+ total: context.totalFrames,
490
+ elapsedMs: performance.now() - context.startedAt,
491
+ segment: segment.index + 1,
492
+ segments: plan.segments.length,
493
+ });
494
+ }
495
+ }
496
+ for (const [key, target] of targets) {
497
+ const session = sessions.get(key);
498
+ if (!session) {
499
+ continue;
500
+ }
501
+ for (const diagnostic of await session.readDiagnostics()) {
502
+ diagnostics.push(compositionDiagnostic(diagnostic, timeline.id, target.clip, target.source.id));
503
+ }
504
+ }
505
+ }
506
+ finally {
507
+ await Promise.all([...sessions.values()].map(async (session) => {
508
+ try {
509
+ await session.close();
510
+ }
511
+ catch {
512
+ // Preserve the check's original error.
513
+ }
514
+ }));
515
+ }
516
+ for (const segment of plan.segments) {
517
+ for (const layer of segment.layers) {
518
+ if (layer.clip.source.kind !== "timeline") {
519
+ continue;
520
+ }
521
+ diagnostics.push(...(await checkResolvedTimelineRange({
522
+ timeline: layer.clip.source.timeline,
523
+ startFrame: layer.sourceStart,
524
+ endFrame: layer.sourceStart + segment.frames,
525
+ context: { ...context, depth: context.depth + 1 },
526
+ })));
527
+ }
528
+ }
529
+ return diagnostics;
530
+ }
531
+ /** Validates a timeline and evaluates every timeline frame without capturing. */
532
+ export async function checkTimeline(options) {
533
+ const startedAt = performance.now();
534
+ const projectTimeline = options.project.timelines.get(options.timelineId);
535
+ if (!projectTimeline) {
536
+ throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
537
+ }
538
+ const resolved = await resolveProjectTimeline({
539
+ project: options.project,
540
+ timeline: projectTimeline,
541
+ ...(options.signal ? { signal: options.signal } : {}),
542
+ ...(options.propsOverrides ? { propsOverrides: options.propsOverrides } : {}),
543
+ });
544
+ if (!resolved.timeline) {
545
+ const first = resolved.diagnostics.find((diagnostic) => diagnostic.severity === "error");
546
+ throw new FrameyardError(first?.code ?? "TIMELINE_INVALID", first?.message ?? "Timeline resolution failed.", {
547
+ diagnostics: resolved.diagnostics,
548
+ });
549
+ }
550
+ const timeline = resolved.timeline;
551
+ const diagnostics = [...resolved.diagnostics];
552
+ const server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
553
+ try {
554
+ diagnostics.push(...(await checkResolvedTimelineRange({
555
+ timeline,
556
+ startFrame: 0,
557
+ endFrame: timeline.durationInFrames,
558
+ context: {
559
+ project: options.project,
560
+ serverUrl: server.url,
561
+ ...(options.signal ? { signal: options.signal } : {}),
562
+ ...(options.onProgress ? { onProgress: options.onProgress } : {}),
563
+ totalFrames: timeline.durationInFrames,
564
+ startedAt,
565
+ depth: 0,
566
+ },
567
+ })));
568
+ }
569
+ finally {
570
+ try {
571
+ await server.close();
572
+ }
573
+ catch {
574
+ // Preserve the check's original error.
575
+ }
576
+ }
577
+ const audio = buildAudioClips(timeline);
578
+ const audioInspection = audio.clips.length
579
+ ? await inspectTimelineAudio({
580
+ projectRoot: options.project.root,
581
+ clips: audio.clips,
582
+ fps: timeline.format.fps,
583
+ durationInFrames: timeline.durationInFrames,
584
+ ...(options.signal ? { signal: options.signal } : {}),
585
+ })
586
+ : { clips: [], assets: [], warnings: [] };
587
+ diagnostics.push(...audioInspection.warnings.map((message) => ({
588
+ severity: "warning",
589
+ code: "AUDIO_WARNING",
590
+ message,
591
+ })));
592
+ const transcripts = audio.transcripts.length
593
+ ? await inspectTranscriptClips({
594
+ projectRoot: options.project.root,
595
+ clips: audio.transcripts,
596
+ ...(options.signal ? { signal: options.signal } : {}),
597
+ })
598
+ : { clips: [], assets: [], warnings: [] };
599
+ diagnostics.push(...transcripts.warnings.map((message) => ({
600
+ severity: "warning",
601
+ code: "TRANSCRIPT_WARNING",
602
+ message,
603
+ })));
604
+ return {
605
+ timeline: options.timelineId,
606
+ entry: timeline.entry,
607
+ format: { ...timeline.format },
608
+ durationInFrames: timeline.durationInFrames,
609
+ framesChecked: timeline.durationInFrames,
610
+ tracks: timeline.tracks.length,
611
+ clips: timeline.clips.length,
612
+ audio: { clips: audioInspection.clips.length, details: audioInspection.clips },
613
+ transcripts: {
614
+ clips: transcripts.clips.length,
615
+ details: transcripts.clips,
616
+ },
617
+ diagnostics: mergeDiagnostics(diagnostics),
618
+ elapsedMs: performance.now() - startedAt,
619
+ };
620
+ }
621
+ async function renderResolvedTimelineFrame(options) {
622
+ if (options.context.depth > 32) {
623
+ throw new FrameyardError("TIMELINE_INVALID", "Nested timeline depth exceeds 32.");
624
+ }
625
+ const { timeline, frame, context } = options;
626
+ const plan = planTimelineRender(timeline, { startFrame: frame, endFrame: frame + 1 });
627
+ const segment = plan.segments[0];
628
+ if (!segment) {
629
+ throw new FrameyardError("FRAME_OUT_OF_RANGE", `Frame ${frame} does not belong to a timeline segment.`);
630
+ }
631
+ const diagnostics = [];
632
+ const layers = [];
633
+ for (const [layerIndex, layer] of segment.layers.entries()) {
634
+ const clip = layer.clip;
635
+ const source = clip.source;
636
+ const transform = clip.transform ?? {
637
+ fit: "contain",
638
+ x: 0,
639
+ y: 0,
640
+ scale: 1,
641
+ rotation: 0,
642
+ opacity: 1,
643
+ };
644
+ const base = {
645
+ fit: transform.fit,
646
+ x: transform.x,
647
+ y: transform.y,
648
+ ...(transform.width !== undefined && transform.height !== undefined
649
+ ? { width: transform.width, height: transform.height }
650
+ : {}),
651
+ scale: transform.scale,
652
+ rotation: transform.rotation,
653
+ opacity: transform.opacity,
654
+ };
655
+ if (source.kind === "composition") {
656
+ const validation = await validateHtmlComposition({
657
+ projectRoot: context.project.root,
658
+ entry: source.entry,
659
+ ...(clip.props !== undefined ? { props: clip.props } : {}),
660
+ });
661
+ if (!validation.info) {
662
+ throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${clip.id}".`, { diagnostics: validation.diagnostics });
663
+ }
664
+ const session = await openCompositionSession({
665
+ serverUrl: context.serverUrl,
666
+ entry: source.entry,
667
+ props: clip.props,
668
+ info: validation.info,
669
+ staticDiagnostics: validation.diagnostics,
670
+ transparentBackground: true,
671
+ });
672
+ const framePath = path.join(context.scratchRoot, `frame-${context.depth}-${layerIndex}-${randomUUID()}.png`);
673
+ try {
674
+ await session.seek(layer.sourceStart);
675
+ const png = await session.capture();
676
+ await writeFile(framePath, png);
677
+ for (const diagnostic of await session.readDiagnostics()) {
678
+ diagnostics.push(compositionDiagnostic(diagnostic, timeline.id, clip, source.id));
679
+ }
680
+ }
681
+ finally {
682
+ await session.close();
683
+ }
684
+ layers.push({
685
+ kind: "image",
686
+ path: framePath,
687
+ sourceWidth: source.metadata.width,
688
+ sourceHeight: source.metadata.height,
689
+ ...base,
690
+ });
691
+ continue;
692
+ }
693
+ if (source.kind === "video" || source.kind === "image") {
694
+ const size = visualSourceSize(clip, timeline);
695
+ layers.push({
696
+ kind: source.kind,
697
+ path: source.absolutePath,
698
+ sourceWidth: size.width,
699
+ sourceHeight: size.height,
700
+ ...base,
701
+ ...(source.kind === "video" ? { sourceStartFrame: layer.sourceStart } : {}),
702
+ });
703
+ continue;
704
+ }
705
+ if (source.kind === "timeline") {
706
+ const nestedPath = path.join(context.scratchRoot, `nested-frame-${context.depth}-${layerIndex}-${randomUUID()}.png`);
707
+ const nestedDiagnostics = await renderResolvedTimelineFrame({
708
+ timeline: source.timeline,
709
+ frame: layer.sourceStart,
710
+ outputPath: nestedPath,
711
+ background: source.timeline.background ?? "transparent",
712
+ context: { ...context, depth: context.depth + 1 },
713
+ });
714
+ diagnostics.push(...nestedDiagnostics);
715
+ layers.push({
716
+ kind: "image",
717
+ path: nestedPath,
718
+ sourceWidth: source.timeline.format.width,
719
+ sourceHeight: source.timeline.format.height,
720
+ ...base,
721
+ });
722
+ continue;
723
+ }
724
+ throw new FrameyardError("TIMELINE_INVALID", `Unsupported visual source for clip "${clip.id}".`);
725
+ }
726
+ await compositeTimelineFrame({
727
+ outputPath: options.outputPath,
728
+ width: timeline.format.width,
729
+ height: timeline.format.height,
730
+ fps: timeline.format.fps,
731
+ background: options.background,
732
+ layers,
733
+ ...(context.signal ? { signal: context.signal } : {}),
734
+ });
735
+ return diagnostics;
736
+ }
737
+ /** Captures one timeline frame as PNG through the clip capture and composite path. */
738
+ export async function captureTimelineStill(options) {
739
+ if (!Number.isInteger(options.frame) || options.frame < 0) {
740
+ throw new FrameyardError("FRAME_OUT_OF_RANGE", `--frame must be a non-negative integer; received ${options.frame}.`);
741
+ }
742
+ if (path.extname(options.outPath).toLowerCase() !== ".png") {
743
+ throw new FrameyardError("USAGE", `Still output must use a .png extension: ${options.outPath}`);
744
+ }
745
+ const overwrite = options.overwrite ?? false;
746
+ await ensureOutputAvailable(options.outPath, overwrite);
747
+ const startedAt = performance.now();
748
+ const projectTimeline = options.project.timelines.get(options.timelineId);
749
+ if (!projectTimeline) {
750
+ throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
751
+ }
752
+ const resolved = await resolveProjectTimeline({
753
+ project: options.project,
754
+ timeline: projectTimeline,
755
+ ...(options.signal ? { signal: options.signal } : {}),
756
+ ...(options.propsOverrides ? { propsOverrides: options.propsOverrides } : {}),
757
+ });
758
+ if (!resolved.timeline) {
759
+ const first = resolved.diagnostics.find((diagnostic) => diagnostic.severity === "error");
760
+ throw new FrameyardError(first?.code ?? "TIMELINE_INVALID", first?.message ?? "Timeline resolution failed.", {
761
+ diagnostics: resolved.diagnostics,
762
+ });
763
+ }
764
+ const timeline = resolved.timeline;
765
+ if (options.frame >= timeline.durationInFrames) {
766
+ throw new FrameyardError("FRAME_OUT_OF_RANGE", `Frame ${options.frame} is outside the valid range 0..${timeline.durationInFrames - 1}.`);
767
+ }
768
+ const scratchBase = options.scratchDir ?? tmpdir();
769
+ await mkdir(scratchBase, { recursive: true });
770
+ const scratchRoot = await mkdtemp(path.join(scratchBase, "frameyard-timeline-still-"));
771
+ const server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
772
+ const tempPath = path.join(path.dirname(options.outPath), `.${path.basename(options.outPath)}.frameyard-${process.pid}-${randomUUID()}.png`);
773
+ try {
774
+ const diagnostics = await renderResolvedTimelineFrame({
775
+ timeline,
776
+ frame: options.frame,
777
+ outputPath: tempPath,
778
+ background: timeline.background ?? "black",
779
+ context: {
780
+ project: options.project,
781
+ serverUrl: server.url,
782
+ scratchRoot,
783
+ depth: 0,
784
+ ...(options.signal ? { signal: options.signal } : {}),
785
+ },
786
+ });
787
+ if (!overwrite && (await pathExists(options.outPath))) {
788
+ throw new FrameyardError("OUTPUT_EXISTS", `Output already exists: ${options.outPath}. Pass --overwrite to replace it.`);
789
+ }
790
+ await publishFile(tempPath, options.outPath, overwrite);
791
+ return {
792
+ timeline: options.timelineId,
793
+ entry: timeline.entry,
794
+ frame: options.frame,
795
+ path: options.outPath,
796
+ width: timeline.format.width,
797
+ height: timeline.format.height,
798
+ diagnostics: mergeDiagnostics(resolved.diagnostics, diagnostics),
799
+ elapsedMs: performance.now() - startedAt,
800
+ };
801
+ }
802
+ finally {
803
+ await rm(tempPath, { force: true });
804
+ try {
805
+ await server.close();
806
+ }
807
+ catch {
808
+ // Preserve the still's original error.
809
+ }
810
+ await rm(scratchRoot, { recursive: true, force: true });
811
+ }
812
+ }
813
+ /** Renders one timeline to MP4 through the segmented lossless clip pipeline. */
814
+ export async function renderTimeline(options) {
815
+ if (path.extname(options.outPath).toLowerCase() !== ".mp4") {
816
+ throw new FrameyardError("USAGE", `Timeline output must use an .mp4 extension: ${options.outPath}`);
817
+ }
818
+ const workers = validateWorkers(options.workers);
819
+ const overwrite = options.overwrite ?? false;
820
+ await ensureOutputAvailable(options.outPath, overwrite);
821
+ await assertFfmpegCapabilities(options.signal);
822
+ const startedAt = performance.now();
823
+ const projectTimeline = options.project.timelines.get(options.timelineId);
824
+ if (!projectTimeline) {
825
+ throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
826
+ }
827
+ const resolved = await resolveProjectTimeline({
828
+ project: options.project,
829
+ timeline: projectTimeline,
830
+ ...(options.signal ? { signal: options.signal } : {}),
831
+ ...(options.propsOverrides ? { propsOverrides: options.propsOverrides } : {}),
832
+ });
833
+ if (!resolved.timeline) {
834
+ const first = resolved.diagnostics.find((diagnostic) => diagnostic.severity === "error");
835
+ throw new FrameyardError(first?.code ?? "TIMELINE_INVALID", first?.message ?? "Timeline resolution failed.", {
836
+ diagnostics: resolved.diagnostics,
837
+ });
838
+ }
839
+ const timeline = resolved.timeline;
840
+ const plan = planTimelineRender(timeline, { maxSegmentFrames: options.maxSegmentFrames });
841
+ const scratchBase = options.scratchDir ?? tmpdir();
842
+ await mkdir(scratchBase, { recursive: true });
843
+ const scratchRoot = await mkdtemp(path.join(scratchBase, "frameyard-timeline-"));
844
+ const server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
845
+ const infoCache = new Map();
846
+ const diagnostics = [...resolved.diagnostics];
847
+ const total = timeline.durationInFrames;
848
+ const segmentResults = Array.from({
849
+ length: plan.segments.length,
850
+ });
851
+ let nextSegment = 0;
852
+ const audio = buildAudioClips(timeline);
853
+ const audioInspection = audio.clips.length
854
+ ? await inspectTimelineAudio({
855
+ projectRoot: options.project.root,
856
+ clips: audio.clips,
857
+ fps: timeline.format.fps,
858
+ durationInFrames: total,
859
+ ...(options.signal ? { signal: options.signal } : {}),
860
+ })
861
+ : { clips: [], assets: [], warnings: [] };
862
+ diagnostics.push(...audioInspection.warnings.map((message) => ({
863
+ severity: "warning",
864
+ code: "AUDIO_WARNING",
865
+ message,
866
+ })));
867
+ const transcripts = audio.transcripts.length
868
+ ? await inspectTranscriptClips({
869
+ projectRoot: options.project.root,
870
+ clips: audio.transcripts,
871
+ ...(options.signal ? { signal: options.signal } : {}),
872
+ })
873
+ : { clips: [], assets: [], warnings: [] };
874
+ diagnostics.push(...transcripts.warnings.map((message) => ({
875
+ severity: "warning",
876
+ code: "TRANSCRIPT_WARNING",
877
+ message,
878
+ })));
879
+ const tempVideo = path.join(path.dirname(options.outPath), `.${path.basename(options.outPath)}.frameyard-${process.pid}-${randomUUID()}.video.mp4`);
880
+ const tempFinal = path.join(path.dirname(options.outPath), `.${path.basename(options.outPath)}.frameyard-${process.pid}-${randomUUID()}.mp4`);
881
+ try {
882
+ const worker = async () => {
883
+ while (true) {
884
+ throwIfAborted(options.signal);
885
+ const index = nextSegment;
886
+ nextSegment += 1;
887
+ if (index >= plan.segments.length) {
888
+ return;
889
+ }
890
+ const segment = plan.segments[index];
891
+ const result = await processTimelineSegment({
892
+ project: options.project,
893
+ timeline,
894
+ segment,
895
+ serverUrl: server.url,
896
+ scratchRoot,
897
+ infoCache,
898
+ ...(options.signal ? { signal: options.signal } : {}),
899
+ });
900
+ segmentResults[index] = result;
901
+ options.onProgress?.({
902
+ frame: segment.end,
903
+ total,
904
+ elapsedMs: performance.now() - startedAt,
905
+ segment: index + 1,
906
+ segments: plan.segments.length,
907
+ });
908
+ }
909
+ };
910
+ await Promise.all(Array.from({ length: workers }, () => worker()));
911
+ const segmentPaths = segmentResults.map((result) => {
912
+ if (!result) {
913
+ throw new FrameyardError("INTERNAL", "A timeline segment was not rendered.");
914
+ }
915
+ diagnostics.push(...result.diagnostics);
916
+ return result.path;
917
+ });
918
+ await concatTimelineSegments({
919
+ segmentPaths,
920
+ listPath: path.join(scratchRoot, "segments.txt"),
921
+ outputPath: tempVideo,
922
+ ...(options.signal ? { signal: options.signal } : {}),
923
+ });
924
+ let finalPath = tempVideo;
925
+ if (audioInspection.assets.length > 0) {
926
+ const audioPath = path.join(scratchRoot, "audio.wav");
927
+ await mixAudioTimeline({
928
+ clips: audioInspection.assets.map((asset) => ({
929
+ path: asset.path,
930
+ startFrame: asset.clip.startFrame,
931
+ trimStart: asset.clip.trimStart,
932
+ ...(asset.clip.trimDuration !== undefined ? { trimDuration: asset.clip.trimDuration } : {}),
933
+ volume: asset.clip.volume,
934
+ })),
935
+ fps: timeline.format.fps,
936
+ durationSeconds: total / timeline.format.fps,
937
+ outputPath: audioPath,
938
+ ...(options.signal ? { signal: options.signal } : {}),
939
+ });
940
+ await muxVideoWithAudio({
941
+ videoPath: tempVideo,
942
+ audioPath,
943
+ outputPath: tempFinal,
944
+ ...(options.signal ? { signal: options.signal } : {}),
945
+ });
946
+ finalPath = tempFinal;
947
+ }
948
+ if (!overwrite && (await pathExists(options.outPath))) {
949
+ throw new FrameyardError("OUTPUT_EXISTS", `Output already exists: ${options.outPath}. Pass --overwrite to replace it.`);
950
+ }
951
+ await publishFile(finalPath, options.outPath, overwrite);
952
+ const uniqueDiagnostics = mergeDiagnostics(diagnostics);
953
+ return {
954
+ timeline: options.timelineId,
955
+ entry: timeline.entry,
956
+ path: options.outPath,
957
+ width: timeline.format.width,
958
+ height: timeline.format.height,
959
+ fps: timeline.format.fps,
960
+ durationInFrames: total,
961
+ durationSeconds: total / timeline.format.fps,
962
+ framesRendered: total,
963
+ segments: plan.segments.length,
964
+ workers,
965
+ bytes: (await stat(options.outPath)).size,
966
+ audio: { clips: audioInspection.clips.length },
967
+ transcripts: { clips: transcripts.clips.length },
968
+ diagnostics: uniqueDiagnostics,
969
+ elapsedMs: performance.now() - startedAt,
970
+ };
971
+ }
972
+ finally {
973
+ await rm(tempVideo, { force: true });
974
+ await rm(tempFinal, { force: true });
975
+ try {
976
+ await server.close();
977
+ }
978
+ catch {
979
+ // Preserve the render's original error.
980
+ }
981
+ await rm(scratchRoot, { recursive: true, force: true });
982
+ }
983
+ }
984
+ //# sourceMappingURL=timeline.js.map