frameyard 0.6.0 → 0.8.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 (161) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.md +40 -16
  3. package/README.md +64 -55
  4. package/dist/assets/process.js.map +1 -1
  5. package/dist/assets/provenance.js.map +1 -1
  6. package/dist/cli.js +255 -59
  7. package/dist/cli.js.map +1 -1
  8. package/dist/composition/attributes.d.ts.map +1 -1
  9. package/dist/composition/descriptor.d.ts +17 -3
  10. package/dist/composition/descriptor.d.ts.map +1 -1
  11. package/dist/composition/descriptor.js +51 -28
  12. package/dist/composition/descriptor.js.map +1 -1
  13. package/dist/composition/diagnostics.js.map +1 -1
  14. package/dist/composition/format.d.ts +12 -0
  15. package/dist/composition/format.d.ts.map +1 -0
  16. package/dist/composition/format.js +28 -0
  17. package/dist/composition/format.js.map +1 -0
  18. package/dist/composition/index.d.ts +2 -1
  19. package/dist/composition/index.d.ts.map +1 -1
  20. package/dist/composition/index.js +1 -0
  21. package/dist/composition/index.js.map +1 -1
  22. package/dist/contracts.d.ts +55 -6
  23. package/dist/contracts.d.ts.map +1 -1
  24. package/dist/contracts.js +3 -0
  25. package/dist/contracts.js.map +1 -1
  26. package/dist/doctor.d.ts.map +1 -1
  27. package/dist/doctor.js +79 -0
  28. package/dist/doctor.js.map +1 -1
  29. package/dist/errors.d.ts +1 -1
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/html/document-adapter.d.ts +3 -3
  33. package/dist/html/document-adapter.d.ts.map +1 -1
  34. package/dist/html/document-adapter.js +1 -1
  35. package/dist/html/document-adapter.js.map +1 -1
  36. package/dist/html/index.d.ts +1 -1
  37. package/dist/html/index.d.ts.map +1 -1
  38. package/dist/html/parse.d.ts +10 -2
  39. package/dist/html/parse.d.ts.map +1 -1
  40. package/dist/html/parse.js +14 -5
  41. package/dist/html/parse.js.map +1 -1
  42. package/dist/html/validate.d.ts +12 -1
  43. package/dist/html/validate.d.ts.map +1 -1
  44. package/dist/html/validate.js +233 -61
  45. package/dist/html/validate.js.map +1 -1
  46. package/dist/index.d.ts +4 -4
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +2 -2
  49. package/dist/index.js.map +1 -1
  50. package/dist/preview/main.d.ts +2 -2
  51. package/dist/preview/main.d.ts.map +1 -1
  52. package/dist/preview/main.js +47 -19
  53. package/dist/preview/main.js.map +1 -1
  54. package/dist/preview/timeline-data.d.ts +5 -1
  55. package/dist/preview/timeline-data.d.ts.map +1 -1
  56. package/dist/preview/timeline-host.d.ts +2 -0
  57. package/dist/preview/timeline-host.d.ts.map +1 -1
  58. package/dist/preview/timeline-host.js +31 -10
  59. package/dist/preview/timeline-host.js.map +1 -1
  60. package/dist/preview/timeline-main.js +12 -7
  61. package/dist/preview/timeline-main.js.map +1 -1
  62. package/dist/project.d.ts +3 -5
  63. package/dist/project.d.ts.map +1 -1
  64. package/dist/project.js.map +1 -1
  65. package/dist/renderer/browser.d.ts +2 -2
  66. package/dist/renderer/browser.d.ts.map +1 -1
  67. package/dist/renderer/browser.js +10 -4
  68. package/dist/renderer/browser.js.map +1 -1
  69. package/dist/renderer/ffmpeg.d.ts +69 -2
  70. package/dist/renderer/ffmpeg.d.ts.map +1 -1
  71. package/dist/renderer/ffmpeg.js +381 -69
  72. package/dist/renderer/ffmpeg.js.map +1 -1
  73. package/dist/renderer/index.d.ts +66 -5
  74. package/dist/renderer/index.d.ts.map +1 -1
  75. package/dist/renderer/index.js +226 -51
  76. package/dist/renderer/index.js.map +1 -1
  77. package/dist/renderer/timeline.d.ts +41 -4
  78. package/dist/renderer/timeline.d.ts.map +1 -1
  79. package/dist/renderer/timeline.js +242 -39
  80. package/dist/renderer/timeline.js.map +1 -1
  81. package/dist/runtime/host.d.ts.map +1 -1
  82. package/dist/runtime/host.js +155 -31
  83. package/dist/runtime/host.js.map +1 -1
  84. package/dist/runtime/media.d.ts.map +1 -1
  85. package/dist/runtime/media.js +3 -6
  86. package/dist/runtime/media.js.map +1 -1
  87. package/dist/runtime/video.d.ts +13 -0
  88. package/dist/runtime/video.d.ts.map +1 -0
  89. package/dist/runtime/video.js +41 -0
  90. package/dist/runtime/video.js.map +1 -0
  91. package/dist/scaffold.d.ts.map +1 -1
  92. package/dist/scaffold.js +107 -103
  93. package/dist/scaffold.js.map +1 -1
  94. package/dist/server/composition-html.d.ts +13 -7
  95. package/dist/server/composition-html.d.ts.map +1 -1
  96. package/dist/server/composition-html.js +65 -16
  97. package/dist/server/composition-html.js.map +1 -1
  98. package/dist/server/preview-html.js +1 -1
  99. package/dist/server/server.d.ts.map +1 -1
  100. package/dist/server/server.js +26 -6
  101. package/dist/server/server.js.map +1 -1
  102. package/dist/server/timeline-preview-data.d.ts.map +1 -1
  103. package/dist/server/timeline-preview-data.js +11 -0
  104. package/dist/server/timeline-preview-data.js.map +1 -1
  105. package/dist/server/timeline-preview-html.d.ts.map +1 -1
  106. package/dist/server/timeline-preview-html.js +2 -1
  107. package/dist/server/timeline-preview-html.js.map +1 -1
  108. package/dist/timeline/plan.js.map +1 -1
  109. package/dist/timeline/resolve.d.ts +4 -3
  110. package/dist/timeline/resolve.d.ts.map +1 -1
  111. package/dist/timeline/resolve.js +14 -4
  112. package/dist/timeline/resolve.js.map +1 -1
  113. package/dist/timeline/schema.d.ts +7 -0
  114. package/dist/timeline/schema.d.ts.map +1 -1
  115. package/dist/timeline/schema.js +50 -0
  116. package/dist/timeline/schema.js.map +1 -1
  117. package/dist/transcript.js.map +1 -1
  118. package/dist/version.d.ts +1 -1
  119. package/dist/version.js +1 -1
  120. package/docs/adr/0007-make-html-documents-the-composition-unit.md +1 -1
  121. package/docs/adr/0008-share-the-composition-document-grammar.md +1 -1
  122. package/docs/adr/0011-compose-timeline-frames-in-a-live-browser-host.md +1 -1
  123. package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +1 -1
  124. package/docs/adr/0013-capture-bounded-multi-frame-stills-for-inspection.md +33 -0
  125. package/docs/adr/0014-compose-timeline-clips-with-css-blend-modes.md +37 -0
  126. package/docs/adr/0015-inherit-composition-format-from-the-project.md +28 -0
  127. package/docs/adr/0016-author-compositions-as-html-fragments.md +32 -0
  128. package/docs/adr/0017-render-timeless-compositions-as-stills.md +99 -0
  129. package/docs/migration-0.4.md +3 -1
  130. package/docs/migration-0.8.md +163 -0
  131. package/docs/preview-testing.md +60 -0
  132. package/docs/v0.1-design.md +52 -42
  133. package/examples/blend/README.md +106 -0
  134. package/examples/blend/assets/ink.mp4 +0 -0
  135. package/examples/blend/compositions/aurora.html +55 -0
  136. package/examples/blend/compositions/backdrop.html +74 -0
  137. package/examples/blend/compositions/labels.html +149 -0
  138. package/examples/blend/scripts/make-ink.sh +26 -0
  139. package/examples/blend/timelines/blend.timeline.json +155 -0
  140. package/examples/blend/video.config.js +11 -0
  141. package/examples/demo/compositions/demo.html +243 -251
  142. package/examples/static/README.md +17 -0
  143. package/examples/static/compositions/still.html +37 -0
  144. package/examples/static/timelines/still.timeline.json +13 -0
  145. package/examples/static/video.config.js +9 -0
  146. package/examples/video/compositions/video-mp4.html +349 -357
  147. package/examples/video/compositions/video-overlay.html +337 -345
  148. package/examples/video/compositions/video.html +349 -357
  149. package/examples/webgl/compositions/webgl.html +205 -213
  150. package/package.json +6 -3
  151. package/skills/frameyard/README.md +7 -5
  152. package/skills/frameyard/SKILL.md +17 -13
  153. package/skills/frameyard/references/common-mistakes.md +30 -6
  154. package/skills/frameyard/references/controllers.md +5 -4
  155. package/skills/frameyard/references/deterministic-authoring.md +42 -36
  156. package/skills/frameyard/references/diagnostics.md +11 -3
  157. package/skills/frameyard/references/failure-recovery.md +28 -7
  158. package/skills/frameyard/references/json-results.md +58 -7
  159. package/skills/frameyard/references/subtitles-karaoke.md +2 -2
  160. package/skills/frameyard/references/timelines.md +21 -15
  161. package/skills/frameyard/scripts/frameyard.mjs +20 -3
@@ -2,12 +2,13 @@ import { link, mkdir, mkdtemp, rename, rm, stat, writeFile } from "node:fs/promi
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
- import { FrameyardError } from "../errors.js";
5
+ import { isPlainObject, } from "../contracts.js";
6
+ import { FrameyardError, isFrameyardError } from "../errors.js";
6
7
  import { validateHtmlComposition } from "../html/validate.js";
7
8
  import { startPreviewServer } from "../server/server.js";
8
9
  import { inspectTimelineAudio } from "./audio.js";
9
10
  import { openCompositionSession } from "./browser.js";
10
- import { assertFfmpegCapabilities, compositeTimelineFrame, compositeTimelineSegment, concatTimelineSegments, encodePngSequenceToFfv1, mixAudioTimeline, muxVideoWithAudio, } from "./ffmpeg.js";
11
+ import { assertContainerCodec, assertFfmpegCapabilities, compositeTimelineFrame, compositeTimelineSegment, concatTimelineSegments, CONTAINER_EXTENSION, defaultCodecForContainer, ENCODER_SESSION_LIMIT, encodePngSequenceToFfv1, mixAudioTimeline, muxVideoWithAudio, resolveVideoEncoder, } from "./ffmpeg.js";
11
12
  import { inspectTranscriptClips } from "./transcripts.js";
12
13
  import { planTimelineRender } from "../timeline/plan.js";
13
14
  import { resolveProjectTimeline, } from "../timeline/resolve.js";
@@ -22,6 +23,13 @@ function validateWorkers(value) {
22
23
  }
23
24
  return workers;
24
25
  }
26
+ /**
27
+ * Caps workers at the encoder's concurrent-session limit. Consumer NVIDIA
28
+ * drivers allow eight NVENC sessions, and each worker opens one encoder.
29
+ */
30
+ export function clampTimelineWorkers(workers, encoder) {
31
+ return Math.min(workers, ENCODER_SESSION_LIMIT[encoder]);
32
+ }
25
33
  function throwIfAborted(signal) {
26
34
  if (signal?.aborted) {
27
35
  throw new FrameyardError("RENDER_FAILED", "Operation cancelled.");
@@ -42,6 +50,25 @@ async function ensureOutputAvailable(outPath, overwrite) {
42
50
  }
43
51
  await mkdir(path.dirname(outPath), { recursive: true });
44
52
  }
53
+ async function assertOutputsAvailable(outPaths, overwrite) {
54
+ if (overwrite) {
55
+ return;
56
+ }
57
+ const conflicts = [];
58
+ for (const outPath of outPaths) {
59
+ if (await pathExists(outPath)) {
60
+ conflicts.push(outPath);
61
+ }
62
+ }
63
+ if (conflicts.length === 0) {
64
+ return;
65
+ }
66
+ const first = conflicts[0];
67
+ const message = conflicts.length === 1
68
+ ? `Output already exists: ${first}. Pass --overwrite to replace it.`
69
+ : `Output already exists: ${first} (and ${conflicts.length - 1} more). Pass --overwrite to replace them.`;
70
+ throw new FrameyardError("OUTPUT_EXISTS", message, { details: { conflicts } });
71
+ }
45
72
  async function publishFile(tempPath, outPath, overwrite) {
46
73
  if (overwrite) {
47
74
  await rename(tempPath, outPath);
@@ -79,6 +106,21 @@ function mergeDiagnostics(...groups) {
79
106
  }
80
107
  return result;
81
108
  }
109
+ function withBatchContext(error, frames, failedFrame) {
110
+ if (!isFrameyardError(error)) {
111
+ return error;
112
+ }
113
+ const details = error.details === undefined
114
+ ? {}
115
+ : isPlainObject(error.details)
116
+ ? { ...error.details }
117
+ : { originalDetails: error.details };
118
+ return new FrameyardError(error.code, error.message, {
119
+ cause: error,
120
+ details: { ...details, frames: [...frames], failedFrame },
121
+ diagnostics: error.diagnostics,
122
+ });
123
+ }
82
124
  function compositionDiagnostic(diagnostic, timelineId, clip, compositionId) {
83
125
  return {
84
126
  ...diagnostic,
@@ -227,6 +269,58 @@ export function resolveTimelineAudio(timeline) {
227
269
  transcripts,
228
270
  };
229
271
  }
272
+ async function captureStaticCompositionLayer(options) {
273
+ const { project, timeline, clip, source, serverUrl, scratchRoot, infoCache, staticCache } = options;
274
+ const propsKey = JSON.stringify(clip.props ?? null);
275
+ const cacheKey = `${source.entry}\u0000${propsKey}`;
276
+ const cached = staticCache.get(cacheKey);
277
+ if (cached) {
278
+ return cached;
279
+ }
280
+ const pending = (async () => {
281
+ let validation = infoCache.get(cacheKey);
282
+ if (!validation) {
283
+ validation = await validateHtmlComposition({
284
+ projectRoot: project.root,
285
+ entry: source.entry,
286
+ ...(clip.props !== undefined ? { props: clip.props } : {}),
287
+ ...(project.format ? { format: project.format } : {}),
288
+ });
289
+ infoCache.set(cacheKey, validation);
290
+ }
291
+ if (!validation.info) {
292
+ throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${clip.id}".`, { diagnostics: validation.diagnostics });
293
+ }
294
+ const session = await openCompositionSession({
295
+ serverUrl,
296
+ entry: source.entry,
297
+ props: clip.props,
298
+ info: validation.info,
299
+ staticDiagnostics: validation.diagnostics,
300
+ transparentBackground: true,
301
+ });
302
+ try {
303
+ throwIfAborted(options.signal);
304
+ await session.seek(0);
305
+ const png = await session.capture();
306
+ const filePath = path.join(scratchRoot, `static-${randomUUID()}.png`);
307
+ await writeFile(filePath, png);
308
+ const diagnostics = (await session.readDiagnostics()).map((diagnostic) => compositionDiagnostic(diagnostic, timeline.id, clip, source.id));
309
+ return { path: filePath, diagnostics };
310
+ }
311
+ finally {
312
+ await session.close();
313
+ }
314
+ })();
315
+ staticCache.set(cacheKey, pending);
316
+ try {
317
+ return await pending;
318
+ }
319
+ catch (error) {
320
+ staticCache.delete(cacheKey);
321
+ throw error;
322
+ }
323
+ }
230
324
  async function captureCompositionLayer(options) {
231
325
  const { project, timeline, clip, source, segment, layerIndex, serverUrl, segmentDir, infoCache } = options;
232
326
  const propsKey = JSON.stringify(clip.props ?? null);
@@ -237,6 +331,7 @@ async function captureCompositionLayer(options) {
237
331
  projectRoot: project.root,
238
332
  entry: source.entry,
239
333
  ...(clip.props !== undefined ? { props: clip.props } : {}),
334
+ ...(project.format ? { format: project.format } : {}),
240
335
  });
241
336
  infoCache.set(cacheKey, validation);
242
337
  }
@@ -299,6 +394,7 @@ async function renderNestedTimelineRange(options) {
299
394
  serverUrl: options.serverUrl,
300
395
  scratchRoot: options.scratchRoot,
301
396
  infoCache: options.infoCache,
397
+ staticCache: options.staticCache,
302
398
  codec: "ffv1",
303
399
  background: options.timeline.background ?? "transparent",
304
400
  prefix,
@@ -315,6 +411,8 @@ async function renderNestedTimelineRange(options) {
315
411
  segmentPaths: results.map((result) => result.path),
316
412
  listPath: path.join(options.scratchRoot, `${prefix}-segments.txt`),
317
413
  outputPath,
414
+ // Nested timeline segments are always lossless FFV1 in Matroska.
415
+ container: "mkv",
318
416
  ...(options.signal ? { signal: options.signal } : {}),
319
417
  });
320
418
  return { path: outputPath, diagnostics };
@@ -351,8 +449,31 @@ async function processTimelineSegment(options) {
351
449
  scale: transform.scale,
352
450
  rotation: transform.rotation,
353
451
  opacity: transform.opacity,
452
+ blend: clip.blend ?? "normal",
354
453
  };
355
454
  if (source.kind === "composition") {
455
+ if (source.metadata.static === true) {
456
+ const captured = await captureStaticCompositionLayer({
457
+ project,
458
+ timeline,
459
+ clip,
460
+ source,
461
+ serverUrl,
462
+ scratchRoot,
463
+ infoCache,
464
+ staticCache: options.staticCache,
465
+ ...(options.signal ? { signal: options.signal } : {}),
466
+ });
467
+ diagnostics.push(...captured.diagnostics);
468
+ layers.push({
469
+ kind: "image",
470
+ path: captured.path,
471
+ sourceWidth: source.metadata.width,
472
+ sourceHeight: source.metadata.height,
473
+ ...base,
474
+ });
475
+ continue;
476
+ }
356
477
  const captured = await captureCompositionLayer({
357
478
  project,
358
479
  timeline,
@@ -396,6 +517,7 @@ async function processTimelineSegment(options) {
396
517
  serverUrl,
397
518
  scratchRoot,
398
519
  infoCache,
520
+ staticCache: options.staticCache,
399
521
  depth: depth + 1,
400
522
  ...(options.signal ? { signal: options.signal } : {}),
401
523
  });
@@ -411,7 +533,7 @@ async function processTimelineSegment(options) {
411
533
  }
412
534
  throw new FrameyardError("TIMELINE_INVALID", `Unsupported visual source for clip "${clip.id}".`);
413
535
  }
414
- const extension = codec === "ffv1" ? "mkv" : "mp4";
536
+ const extension = codec === "ffv1" ? "mkv" : CONTAINER_EXTENSION[options.container ?? "mp4"];
415
537
  const segmentPath = path.join(scratchRoot, `${prefix}-segment-${String(segment.index).padStart(4, "0")}.${extension}`);
416
538
  await compositeTimelineSegment({
417
539
  outputPath: segmentPath,
@@ -422,6 +544,8 @@ async function processTimelineSegment(options) {
422
544
  background,
423
545
  layers,
424
546
  codec,
547
+ ...(options.container !== undefined ? { container: options.container } : {}),
548
+ ...(options.encoder !== undefined ? { encoder: options.encoder } : {}),
425
549
  ...(options.signal ? { signal: options.signal } : {}),
426
550
  });
427
551
  await rm(segmentDir, { recursive: true, force: true });
@@ -447,6 +571,7 @@ async function checkResolvedTimelineRange(options) {
447
571
  projectRoot: context.project.root,
448
572
  entry: source.entry,
449
573
  ...(layer.clip.props !== undefined ? { props: layer.clip.props } : {}),
574
+ ...(context.project.format ? { format: context.project.format } : {}),
450
575
  });
451
576
  if (!validation.info) {
452
577
  throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${layer.clip.id}".`, { diagnostics: validation.diagnostics });
@@ -466,6 +591,9 @@ async function checkResolvedTimelineRange(options) {
466
591
  staticDiagnostics: target.validation.diagnostics,
467
592
  });
468
593
  sessions.set(key, session);
594
+ if (target.source.metadata.static === true) {
595
+ await session.seek(0);
596
+ }
469
597
  }
470
598
  for (const segment of plan.segments) {
471
599
  for (let frame = segment.start; frame < segment.end; frame += 1) {
@@ -475,6 +603,9 @@ async function checkResolvedTimelineRange(options) {
475
603
  continue;
476
604
  }
477
605
  const source = layer.clip.source;
606
+ if (source.metadata.static === true) {
607
+ continue;
608
+ }
478
609
  const key = `${source.entry}\u0000${JSON.stringify(layer.clip.props ?? null)}`;
479
610
  const session = sessions.get(key);
480
611
  if (!session) {
@@ -651,12 +782,14 @@ async function renderResolvedTimelineFrame(options) {
651
782
  scale: transform.scale,
652
783
  rotation: transform.rotation,
653
784
  opacity: transform.opacity,
785
+ blend: clip.blend ?? "normal",
654
786
  };
655
787
  if (source.kind === "composition") {
656
788
  const validation = await validateHtmlComposition({
657
789
  projectRoot: context.project.root,
658
790
  entry: source.entry,
659
791
  ...(clip.props !== undefined ? { props: clip.props } : {}),
792
+ ...(context.project.format ? { format: context.project.format } : {}),
660
793
  });
661
794
  if (!validation.info) {
662
795
  throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${clip.id}".`, { diagnostics: validation.diagnostics });
@@ -734,16 +867,31 @@ async function renderResolvedTimelineFrame(options) {
734
867
  });
735
868
  return diagnostics;
736
869
  }
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}.`);
870
+ /** Captures timeline frames as PNGs through the clip capture and composite path. */
871
+ export async function captureTimelineStills(options) {
872
+ if (options.frames.length === 0) {
873
+ throw new FrameyardError("USAGE", "Still capture requires at least one frame.");
874
+ }
875
+ if (options.frames.length !== options.outPaths.length) {
876
+ throw new FrameyardError("USAGE", "Still frames and output paths must have the same length.");
877
+ }
878
+ for (const frame of options.frames) {
879
+ if (!Number.isInteger(frame) || frame < 0) {
880
+ throw new FrameyardError("FRAME_OUT_OF_RANGE", `--frame must be a non-negative integer; received ${frame}.`);
881
+ }
882
+ }
883
+ if (new Set(options.frames).size !== options.frames.length) {
884
+ throw new FrameyardError("USAGE", "Still frames must be unique.");
741
885
  }
742
- if (path.extname(options.outPath).toLowerCase() !== ".png") {
743
- throw new FrameyardError("USAGE", `Still output must use a .png extension: ${options.outPath}`);
886
+ if (new Set(options.outPaths).size !== options.outPaths.length) {
887
+ throw new FrameyardError("USAGE", "Still output paths must be unique.");
888
+ }
889
+ for (const outPath of options.outPaths) {
890
+ if (path.extname(outPath).toLowerCase() !== ".png") {
891
+ throw new FrameyardError("USAGE", `Still output must use a .png extension: ${outPath}`);
892
+ }
744
893
  }
745
894
  const overwrite = options.overwrite ?? false;
746
- await ensureOutputAvailable(options.outPath, overwrite);
747
895
  const startedAt = performance.now();
748
896
  const projectTimeline = options.project.timelines.get(options.timelineId);
749
897
  if (!projectTimeline) {
@@ -762,37 +910,58 @@ export async function captureTimelineStill(options) {
762
910
  });
763
911
  }
764
912
  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}.`);
913
+ const invalidFrames = options.frames.filter((frame) => frame >= timeline.durationInFrames);
914
+ if (invalidFrames.length > 0) {
915
+ const first = invalidFrames[0];
916
+ throw new FrameyardError("FRAME_OUT_OF_RANGE", `Frame ${first} is outside the valid range 0..${timeline.durationInFrames - 1}.`, { details: { invalidFrames, validRange: { min: 0, max: timeline.durationInFrames - 1 } } });
917
+ }
918
+ await assertOutputsAvailable(options.outPaths, overwrite);
919
+ for (const outPath of options.outPaths) {
920
+ await mkdir(path.dirname(outPath), { recursive: true });
767
921
  }
768
922
  const scratchBase = options.scratchDir ?? tmpdir();
769
923
  await mkdir(scratchBase, { recursive: true });
770
924
  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`);
925
+ const tempPaths = options.outPaths.map((outPath) => path.join(path.dirname(outPath), `.${path.basename(outPath)}.frameyard-${process.pid}-${randomUUID()}.png`));
926
+ let server;
773
927
  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.`);
928
+ server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
929
+ const diagnostics = [];
930
+ for (const [index, frame] of options.frames.entries()) {
931
+ throwIfAborted(options.signal);
932
+ try {
933
+ diagnostics.push(...(await renderResolvedTimelineFrame({
934
+ timeline,
935
+ frame,
936
+ outputPath: tempPaths[index],
937
+ background: timeline.background ?? "black",
938
+ context: {
939
+ project: options.project,
940
+ serverUrl: server.url,
941
+ scratchRoot,
942
+ depth: 0,
943
+ ...(options.signal ? { signal: options.signal } : {}),
944
+ },
945
+ })));
946
+ }
947
+ catch (error) {
948
+ throw withBatchContext(error, options.frames, frame);
949
+ }
789
950
  }
790
- await publishFile(tempPath, options.outPath, overwrite);
951
+ await assertOutputsAvailable(options.outPaths, overwrite);
952
+ for (const [index, outPath] of options.outPaths.entries()) {
953
+ await publishFile(tempPaths[index], outPath, overwrite);
954
+ }
955
+ const frames = options.frames.map((frame, index) => ({
956
+ frame,
957
+ path: options.outPaths[index],
958
+ }));
959
+ const first = frames[0];
791
960
  return {
792
961
  timeline: options.timelineId,
793
962
  entry: timeline.entry,
794
- frame: options.frame,
795
- path: options.outPath,
963
+ ...(frames.length === 1 && first !== undefined ? { frame: first.frame, path: first.path } : {}),
964
+ frames,
796
965
  width: timeline.format.width,
797
966
  height: timeline.format.height,
798
967
  diagnostics: mergeDiagnostics(resolved.diagnostics, diagnostics),
@@ -800,9 +969,9 @@ export async function captureTimelineStill(options) {
800
969
  };
801
970
  }
802
971
  finally {
803
- await rm(tempPath, { force: true });
972
+ await Promise.all(tempPaths.map((tempPath) => rm(tempPath, { force: true })));
804
973
  try {
805
- await server.close();
974
+ await server?.close();
806
975
  }
807
976
  catch {
808
977
  // Preserve the still's original error.
@@ -810,16 +979,31 @@ export async function captureTimelineStill(options) {
810
979
  await rm(scratchRoot, { recursive: true, force: true });
811
980
  }
812
981
  }
982
+ /** Captures one timeline frame as a PNG through the clip capture and composite path. */
983
+ export async function captureTimelineStill(options) {
984
+ const { frame, outPath, ...rest } = options;
985
+ return captureTimelineStills({
986
+ ...rest,
987
+ frames: [frame],
988
+ outPaths: [outPath],
989
+ });
990
+ }
813
991
  /** Renders one timeline to MP4 through the segmented lossless clip pipeline. */
814
992
  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}`);
993
+ const container = options.container ?? "mp4";
994
+ const codec = options.codec ?? defaultCodecForContainer(container);
995
+ assertContainerCodec(container, codec);
996
+ const extension = `.${CONTAINER_EXTENSION[container]}`;
997
+ if (path.extname(options.outPath).toLowerCase() !== extension) {
998
+ throw new FrameyardError("USAGE", `Timeline output for --container ${container} must use a ${extension} extension: ${options.outPath}`);
817
999
  }
818
- const workers = validateWorkers(options.workers);
1000
+ const requestedWorkers = validateWorkers(options.workers);
819
1001
  const overwrite = options.overwrite ?? false;
820
- await ensureOutputAvailable(options.outPath, overwrite);
821
- await assertFfmpegCapabilities(options.signal);
822
1002
  const startedAt = performance.now();
1003
+ await ensureOutputAvailable(options.outPath, overwrite);
1004
+ await assertFfmpegCapabilities(options.signal, codec);
1005
+ const encoder = await resolveVideoEncoder(codec, options.encoder, options.signal);
1006
+ const workers = clampTimelineWorkers(requestedWorkers, encoder);
823
1007
  const projectTimeline = options.project.timelines.get(options.timelineId);
824
1008
  if (!projectTimeline) {
825
1009
  throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
@@ -843,7 +1027,15 @@ export async function renderTimeline(options) {
843
1027
  const scratchRoot = await mkdtemp(path.join(scratchBase, "frameyard-timeline-"));
844
1028
  const server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
845
1029
  const infoCache = new Map();
1030
+ const staticCache = new Map();
846
1031
  const diagnostics = [...resolved.diagnostics];
1032
+ if (workers < requestedWorkers) {
1033
+ diagnostics.push({
1034
+ severity: "warning",
1035
+ code: "ENCODER_WARNING",
1036
+ message: `Encoder ${encoder} allows ${workers} concurrent encode sessions; using ${workers} workers instead of ${requestedWorkers}.`,
1037
+ });
1038
+ }
847
1039
  const total = timeline.durationInFrames;
848
1040
  const segmentResults = Array.from({
849
1041
  length: plan.segments.length,
@@ -895,6 +1087,10 @@ export async function renderTimeline(options) {
895
1087
  serverUrl: server.url,
896
1088
  scratchRoot,
897
1089
  infoCache,
1090
+ staticCache,
1091
+ codec,
1092
+ container,
1093
+ encoder,
898
1094
  ...(options.signal ? { signal: options.signal } : {}),
899
1095
  });
900
1096
  segmentResults[index] = result;
@@ -919,6 +1115,8 @@ export async function renderTimeline(options) {
919
1115
  segmentPaths,
920
1116
  listPath: path.join(scratchRoot, "segments.txt"),
921
1117
  outputPath: tempVideo,
1118
+ codec,
1119
+ container,
922
1120
  ...(options.signal ? { signal: options.signal } : {}),
923
1121
  });
924
1122
  let finalPath = tempVideo;
@@ -941,6 +1139,8 @@ export async function renderTimeline(options) {
941
1139
  videoPath: tempVideo,
942
1140
  audioPath,
943
1141
  outputPath: tempFinal,
1142
+ codec,
1143
+ container,
944
1144
  ...(options.signal ? { signal: options.signal } : {}),
945
1145
  });
946
1146
  finalPath = tempFinal;
@@ -962,6 +1162,9 @@ export async function renderTimeline(options) {
962
1162
  framesRendered: total,
963
1163
  segments: plan.segments.length,
964
1164
  workers,
1165
+ codec,
1166
+ container,
1167
+ encoder,
965
1168
  bytes: (await stat(options.outPath)).size,
966
1169
  audio: { clips: audioInspection.clips.length },
967
1170
  transcripts: { clips: transcripts.clips.length },