frameyard 0.8.0 → 0.10.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 (327) hide show
  1. package/CHANGELOG.md +177 -14
  2. package/GLOSSARY.md +112 -2
  3. package/README.md +302 -265
  4. package/dist/assets/integrations.d.ts +8 -4
  5. package/dist/assets/integrations.d.ts.map +1 -1
  6. package/dist/assets/integrations.js +8 -4
  7. package/dist/assets/integrations.js.map +1 -1
  8. package/dist/assets/provenance.d.ts +2 -2
  9. package/dist/assets/provenance.d.ts.map +1 -1
  10. package/dist/assets/provenance.js +5 -3
  11. package/dist/assets/provenance.js.map +1 -1
  12. package/dist/assets/tts-supertonic.d.ts +28 -0
  13. package/dist/assets/tts-supertonic.d.ts.map +1 -0
  14. package/dist/assets/tts-supertonic.js +156 -0
  15. package/dist/assets/tts-supertonic.js.map +1 -0
  16. package/dist/assets/tts.d.ts +18 -6
  17. package/dist/assets/tts.d.ts.map +1 -1
  18. package/dist/assets/tts.js +219 -97
  19. package/dist/assets/tts.js.map +1 -1
  20. package/dist/cli.js +1685 -93
  21. package/dist/cli.js.map +1 -1
  22. package/dist/composition/format.d.ts +1 -1
  23. package/dist/composition/format.d.ts.map +1 -1
  24. package/dist/composition/format.js +1 -1
  25. package/dist/composition/format.js.map +1 -1
  26. package/dist/context.d.ts +82 -0
  27. package/dist/context.d.ts.map +1 -0
  28. package/dist/context.js +277 -0
  29. package/dist/context.js.map +1 -0
  30. package/dist/contracts.d.ts +25 -0
  31. package/dist/contracts.d.ts.map +1 -1
  32. package/dist/contracts.js.map +1 -1
  33. package/dist/doctor.d.ts +14 -1
  34. package/dist/doctor.d.ts.map +1 -1
  35. package/dist/doctor.js +166 -7
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/errors.d.ts +1 -1
  38. package/dist/errors.d.ts.map +1 -1
  39. package/dist/errors.js.map +1 -1
  40. package/dist/fonts/add.d.ts +41 -0
  41. package/dist/fonts/add.d.ts.map +1 -0
  42. package/dist/fonts/add.js +259 -0
  43. package/dist/fonts/add.js.map +1 -0
  44. package/dist/fonts/binary.d.ts +4 -0
  45. package/dist/fonts/binary.d.ts.map +1 -0
  46. package/dist/fonts/binary.js +21 -0
  47. package/dist/fonts/binary.js.map +1 -0
  48. package/dist/fonts/css.d.ts +4 -0
  49. package/dist/fonts/css.d.ts.map +1 -0
  50. package/dist/fonts/css.js +50 -0
  51. package/dist/fonts/css.js.map +1 -0
  52. package/dist/fonts/fontsource.d.ts +107 -0
  53. package/dist/fonts/fontsource.d.ts.map +1 -0
  54. package/dist/fonts/fontsource.js +565 -0
  55. package/dist/fonts/fontsource.js.map +1 -0
  56. package/dist/fonts/index.d.ts +11 -0
  57. package/dist/fonts/index.d.ts.map +1 -0
  58. package/dist/fonts/index.js +11 -0
  59. package/dist/fonts/index.js.map +1 -0
  60. package/dist/fonts/install.d.ts +24 -0
  61. package/dist/fonts/install.d.ts.map +1 -0
  62. package/dist/fonts/install.js +145 -0
  63. package/dist/fonts/install.js.map +1 -0
  64. package/dist/fonts/manifest.d.ts +21 -0
  65. package/dist/fonts/manifest.d.ts.map +1 -0
  66. package/dist/fonts/manifest.js +96 -0
  67. package/dist/fonts/manifest.js.map +1 -0
  68. package/dist/fonts/options.d.ts +9 -0
  69. package/dist/fonts/options.d.ts.map +1 -0
  70. package/dist/fonts/options.js +66 -0
  71. package/dist/fonts/options.js.map +1 -0
  72. package/dist/fonts/schema.d.ts +4 -0
  73. package/dist/fonts/schema.d.ts.map +1 -0
  74. package/dist/fonts/schema.js +316 -0
  75. package/dist/fonts/schema.js.map +1 -0
  76. package/dist/fonts/summary.d.ts +16 -0
  77. package/dist/fonts/summary.d.ts.map +1 -0
  78. package/dist/fonts/summary.js +22 -0
  79. package/dist/fonts/summary.js.map +1 -0
  80. package/dist/fonts/types.d.ts +56 -0
  81. package/dist/fonts/types.d.ts.map +1 -0
  82. package/dist/fonts/types.js +8 -0
  83. package/dist/fonts/types.js.map +1 -0
  84. package/dist/fonts/verify.d.ts +33 -0
  85. package/dist/fonts/verify.d.ts.map +1 -0
  86. package/dist/fonts/verify.js +200 -0
  87. package/dist/fonts/verify.js.map +1 -0
  88. package/dist/html/validate.d.ts.map +1 -1
  89. package/dist/html/validate.js +59 -0
  90. package/dist/html/validate.js.map +1 -1
  91. package/dist/index.d.ts +3 -3
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +1 -1
  94. package/dist/index.js.map +1 -1
  95. package/dist/inspect.d.ts +61 -0
  96. package/dist/inspect.d.ts.map +1 -0
  97. package/dist/inspect.js +325 -0
  98. package/dist/inspect.js.map +1 -0
  99. package/dist/lint.d.ts +63 -0
  100. package/dist/lint.d.ts.map +1 -0
  101. package/dist/lint.js +265 -0
  102. package/dist/lint.js.map +1 -0
  103. package/dist/policy.d.ts +69 -0
  104. package/dist/policy.d.ts.map +1 -0
  105. package/dist/policy.js +361 -0
  106. package/dist/policy.js.map +1 -0
  107. package/dist/preview/fonts/LICENSE-figtree.txt +93 -0
  108. package/dist/preview/fonts/LICENSE-space-mono.txt +93 -0
  109. package/dist/preview/fonts/README.md +23 -0
  110. package/dist/preview/fonts/figtree-latin-wght-normal.woff2 +0 -0
  111. package/dist/preview/fonts/space-mono-latin-400-normal.woff2 +0 -0
  112. package/dist/preview/fonts/space-mono-latin-700-normal.woff2 +0 -0
  113. package/dist/preview/main.js +5 -2
  114. package/dist/preview/main.js.map +1 -1
  115. package/dist/preview/timeline-data.d.ts +17 -69
  116. package/dist/preview/timeline-data.d.ts.map +1 -1
  117. package/dist/preview/timeline-host.d.ts.map +1 -1
  118. package/dist/preview/timeline-host.js +70 -45
  119. package/dist/preview/timeline-host.js.map +1 -1
  120. package/dist/preview/timeline-main.d.ts.map +1 -1
  121. package/dist/preview/timeline-main.js +88 -27
  122. package/dist/preview/timeline-main.js.map +1 -1
  123. package/dist/project.d.ts +10 -3
  124. package/dist/project.d.ts.map +1 -1
  125. package/dist/project.js +20 -17
  126. package/dist/project.js.map +1 -1
  127. package/dist/renderer/audio.d.ts +6 -0
  128. package/dist/renderer/audio.d.ts.map +1 -1
  129. package/dist/renderer/audio.js +3 -1
  130. package/dist/renderer/audio.js.map +1 -1
  131. package/dist/renderer/browser.d.ts.map +1 -1
  132. package/dist/renderer/browser.js +13 -0
  133. package/dist/renderer/browser.js.map +1 -1
  134. package/dist/renderer/concurrency.d.ts +6 -0
  135. package/dist/renderer/concurrency.d.ts.map +1 -0
  136. package/dist/renderer/concurrency.js +27 -0
  137. package/dist/renderer/concurrency.js.map +1 -0
  138. package/dist/renderer/ffmpeg.d.ts +104 -5
  139. package/dist/renderer/ffmpeg.d.ts.map +1 -1
  140. package/dist/renderer/ffmpeg.js +580 -67
  141. package/dist/renderer/ffmpeg.js.map +1 -1
  142. package/dist/renderer/index.js +1 -1
  143. package/dist/renderer/index.js.map +1 -1
  144. package/dist/renderer/keyframe-expressions.d.ts +39 -0
  145. package/dist/renderer/keyframe-expressions.d.ts.map +1 -0
  146. package/dist/renderer/keyframe-expressions.js +180 -0
  147. package/dist/renderer/keyframe-expressions.js.map +1 -0
  148. package/dist/renderer/session-pool.d.ts +28 -0
  149. package/dist/renderer/session-pool.d.ts.map +1 -0
  150. package/dist/renderer/session-pool.js +91 -0
  151. package/dist/renderer/session-pool.js.map +1 -0
  152. package/dist/renderer/timeline.d.ts +2 -0
  153. package/dist/renderer/timeline.d.ts.map +1 -1
  154. package/dist/renderer/timeline.js +600 -147
  155. package/dist/renderer/timeline.js.map +1 -1
  156. package/dist/renderer/timings.d.ts +13 -0
  157. package/dist/renderer/timings.d.ts.map +1 -0
  158. package/dist/renderer/timings.js +29 -0
  159. package/dist/renderer/timings.js.map +1 -0
  160. package/dist/runtime/host.d.ts.map +1 -1
  161. package/dist/runtime/host.js +4 -0
  162. package/dist/runtime/host.js.map +1 -1
  163. package/dist/scaffold.d.ts +3 -0
  164. package/dist/scaffold.d.ts.map +1 -1
  165. package/dist/scaffold.js +48 -320
  166. package/dist/scaffold.js.map +1 -1
  167. package/dist/server/composition-html.d.ts +2 -0
  168. package/dist/server/composition-html.d.ts.map +1 -1
  169. package/dist/server/composition-html.js +7 -0
  170. package/dist/server/composition-html.js.map +1 -1
  171. package/dist/server/preview-html.d.ts.map +1 -1
  172. package/dist/server/preview-html.js +74 -188
  173. package/dist/server/preview-html.js.map +1 -1
  174. package/dist/server/preview-style.d.ts +11 -0
  175. package/dist/server/preview-style.d.ts.map +1 -0
  176. package/dist/server/preview-style.js +298 -0
  177. package/dist/server/preview-style.js.map +1 -0
  178. package/dist/server/server.d.ts.map +1 -1
  179. package/dist/server/server.js +31 -1
  180. package/dist/server/server.js.map +1 -1
  181. package/dist/server/timeline-preview-data.d.ts.map +1 -1
  182. package/dist/server/timeline-preview-data.js +12 -34
  183. package/dist/server/timeline-preview-data.js.map +1 -1
  184. package/dist/server/timeline-preview-html.d.ts.map +1 -1
  185. package/dist/server/timeline-preview-html.js +140 -148
  186. package/dist/server/timeline-preview-html.js.map +1 -1
  187. package/dist/templates.d.ts +16 -0
  188. package/dist/templates.d.ts.map +1 -0
  189. package/dist/templates.js +186 -0
  190. package/dist/templates.js.map +1 -0
  191. package/dist/timeline/easing.d.ts +36 -0
  192. package/dist/timeline/easing.d.ts.map +1 -0
  193. package/dist/timeline/easing.js +225 -0
  194. package/dist/timeline/easing.js.map +1 -0
  195. package/dist/timeline/keyframes.d.ts +23 -0
  196. package/dist/timeline/keyframes.d.ts.map +1 -0
  197. package/dist/timeline/keyframes.js +95 -0
  198. package/dist/timeline/keyframes.js.map +1 -0
  199. package/dist/timeline/plan.d.ts +6 -12
  200. package/dist/timeline/plan.d.ts.map +1 -1
  201. package/dist/timeline/plan.js +17 -30
  202. package/dist/timeline/plan.js.map +1 -1
  203. package/dist/timeline/resolve.d.ts +12 -0
  204. package/dist/timeline/resolve.d.ts.map +1 -1
  205. package/dist/timeline/resolve.js +105 -9
  206. package/dist/timeline/resolve.js.map +1 -1
  207. package/dist/timeline/scene.d.ts +126 -0
  208. package/dist/timeline/scene.d.ts.map +1 -0
  209. package/dist/timeline/scene.js +109 -0
  210. package/dist/timeline/scene.js.map +1 -0
  211. package/dist/timeline/schema.d.ts +31 -0
  212. package/dist/timeline/schema.d.ts.map +1 -1
  213. package/dist/timeline/schema.js +240 -9
  214. package/dist/timeline/schema.js.map +1 -1
  215. package/dist/timeline/transitions.d.ts +21 -0
  216. package/dist/timeline/transitions.d.ts.map +1 -0
  217. package/dist/timeline/transitions.js +23 -0
  218. package/dist/timeline/transitions.js.map +1 -0
  219. package/dist/version.d.ts +1 -1
  220. package/dist/version.d.ts.map +1 -1
  221. package/dist/version.js +1 -1
  222. package/dist/version.js.map +1 -1
  223. package/docs/adr/0007-make-html-documents-the-composition-unit.md +1 -1
  224. package/docs/adr/0010-make-timelines-the-render-unit.md +3 -2
  225. package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +1 -1
  226. package/docs/adr/0014-compose-timeline-clips-with-css-blend-modes.md +2 -1
  227. package/docs/adr/0015-inherit-composition-format-from-the-project.md +2 -2
  228. package/docs/adr/0016-author-compositions-as-html-fragments.md +1 -1
  229. package/docs/adr/0018-animate-timeline-clips-with-keyframes.md +35 -0
  230. package/docs/adr/0019-make-same-track-overlap-the-transition-window.md +36 -0
  231. package/docs/adr/0020-crossfade-audio-clips-on-audio-tracks.md +27 -0
  232. package/docs/adr/0021-project-timeline-scenes-once.md +32 -0
  233. package/docs/adr/0022-gate-heavy-operations-with-a-workspace-resource-policy.md +35 -0
  234. package/docs/adr/0023-pin-fonts-as-project-assets.md +98 -0
  235. package/docs/adr/0024-stream-composition-captures-and-skip-trivial-composites.md +86 -0
  236. package/docs/adr/0025-capture-timeline-layers-in-parallel-with-a-global-limit.md +67 -0
  237. package/docs/adr/0026-reuse-loaded-composition-sessions-across-segments.md +70 -0
  238. package/docs/adr/0027-encode-two-full-frame-layers-through-dual-pipes.md +76 -0
  239. package/docs/adr/0028-encode-full-frame-video-segments-directly.md +65 -0
  240. package/docs/migration-0.4.md +2 -2
  241. package/docs/preview-testing.md +27 -6
  242. package/docs/{v0.1-design.md → v0.x-design.md} +57 -32
  243. package/examples/blend/README.md +3 -3
  244. package/examples/demo/README.md +2 -2
  245. package/examples/intro/README.md +71 -0
  246. package/examples/intro/assets/fonts/figtree/LICENSE.txt +93 -0
  247. package/examples/intro/assets/fonts/figtree/figtree-latin-wght-normal.woff2 +0 -0
  248. package/examples/intro/assets/fonts/instrument-serif/LICENSE.txt +93 -0
  249. package/examples/intro/assets/fonts/instrument-serif/instrument-serif-latin-400-italic.woff2 +0 -0
  250. package/examples/intro/assets/fonts/space-mono/LICENSE.txt +93 -0
  251. package/examples/intro/assets/fonts/space-mono/space-mono-latin-400-normal.woff2 +0 -0
  252. package/examples/intro/assets/fonts/space-mono/space-mono-latin-700-normal.woff2 +0 -0
  253. package/examples/intro/assets/grain.png +0 -0
  254. package/examples/intro/assets/score.ogg +0 -0
  255. package/examples/intro/compositions/brand.html +412 -0
  256. package/examples/intro/compositions/chrome.controller.js +63 -0
  257. package/examples/intro/compositions/chrome.html +256 -0
  258. package/examples/intro/compositions/lattice.controller.js +148 -0
  259. package/examples/intro/compositions/lattice.html +308 -0
  260. package/examples/intro/compositions/opening.html +504 -0
  261. package/examples/intro/compositions/studio.html +930 -0
  262. package/examples/intro/compositions/theme.css +218 -0
  263. package/examples/intro/fy.config.js +13 -0
  264. package/examples/intro/fy.fonts.json +100 -0
  265. package/examples/intro/scripts/make-grain.mjs +79 -0
  266. package/examples/intro/scripts/make-score.mjs +702 -0
  267. package/examples/intro/timelines/intro.timeline.json +60 -0
  268. package/examples/keyframes/README.md +76 -0
  269. package/examples/keyframes/compositions/backdrop.html +60 -0
  270. package/examples/keyframes/compositions/card.html +99 -0
  271. package/examples/keyframes/fy.config.js +10 -0
  272. package/examples/keyframes/timelines/keyframes.timeline.json +60 -0
  273. package/examples/nested/README.md +112 -0
  274. package/examples/nested/assets/pulse.wav +0 -0
  275. package/examples/nested/compositions/backdrop.html +84 -0
  276. package/examples/nested/compositions/card.html +275 -0
  277. package/examples/nested/compositions/overlay.html +307 -0
  278. package/examples/nested/fy.config.js +13 -0
  279. package/examples/nested/scripts/make-pulse.sh +20 -0
  280. package/examples/nested/timelines/chapter-a.timeline.json +39 -0
  281. package/examples/nested/timelines/chapter-b.timeline.json +39 -0
  282. package/examples/nested/timelines/nested-reel.timeline.json +52 -0
  283. package/examples/static/README.md +2 -2
  284. package/examples/transitions/README.md +128 -0
  285. package/examples/transitions/assets/alpha.wav +0 -0
  286. package/examples/transitions/assets/beta.wav +0 -0
  287. package/examples/transitions/assets/gamma.wav +0 -0
  288. package/examples/transitions/compositions/alpha.html +56 -0
  289. package/examples/transitions/compositions/beta.html +56 -0
  290. package/examples/transitions/compositions/gamma.html +56 -0
  291. package/examples/transitions/fy.config.js +12 -0
  292. package/examples/transitions/timelines/audio-crossfade.timeline.json +44 -0
  293. package/examples/transitions/timelines/transitions.timeline.json +31 -0
  294. package/examples/video/README.md +4 -4
  295. package/examples/webgl/README.md +1 -1
  296. package/package.json +13 -6
  297. package/scaffolds/init/AGENTS.md +131 -0
  298. package/scaffolds/init/README.md +56 -0
  299. package/scaffolds/init/compositions/hello.html +79 -0
  300. package/scaffolds/init/fy.config.js +9 -0
  301. package/scaffolds/init/gitignore.template +1 -0
  302. package/scaffolds/init/package.json +11 -0
  303. package/scaffolds/init/props/hello.json +4 -0
  304. package/scaffolds/init/props/main.json +6 -0
  305. package/scaffolds/init/timelines/main.timeline.json +20 -0
  306. package/scaffolds/local-models/LOCAL-MODELS.md +77 -0
  307. package/scaffolds/local-models/fy.policy.json +5 -0
  308. package/scaffolds/local-models/opencode/opencode.jsonc +17 -0
  309. package/scaffolds/local-models/pi/APPEND_SYSTEM.md +21 -0
  310. package/scaffolds/local-models/pi/sandbox.json +15 -0
  311. package/scaffolds/local-models/pi/settings.json +15 -0
  312. package/skills/frameyard/README.md +1 -0
  313. package/skills/frameyard/SKILL.md +59 -61
  314. package/skills/frameyard/references/audio-assets.md +32 -3
  315. package/skills/frameyard/references/common-mistakes.md +53 -2
  316. package/skills/frameyard/references/deterministic-authoring.md +10 -3
  317. package/skills/frameyard/references/diagnostics.md +31 -1
  318. package/skills/frameyard/references/failure-recovery.md +32 -6
  319. package/skills/frameyard/references/fonts.md +132 -0
  320. package/skills/frameyard/references/json-results.md +119 -6
  321. package/skills/frameyard/references/quick-reference.md +222 -0
  322. package/skills/frameyard/references/timelines.md +97 -7
  323. /package/examples/blend/{video.config.js → fy.config.js} +0 -0
  324. /package/examples/demo/{video.config.js → fy.config.js} +0 -0
  325. /package/examples/static/{video.config.js → fy.config.js} +0 -0
  326. /package/examples/video/{video.config.js → fy.config.js} +0 -0
  327. /package/examples/webgl/{video.config.js → fy.config.js} +0 -0
@@ -8,9 +8,16 @@ import { validateHtmlComposition } from "../html/validate.js";
8
8
  import { startPreviewServer } from "../server/server.js";
9
9
  import { inspectTimelineAudio } from "./audio.js";
10
10
  import { openCompositionSession } from "./browser.js";
11
- import { assertContainerCodec, assertFfmpegCapabilities, compositeTimelineFrame, compositeTimelineSegment, concatTimelineSegments, CONTAINER_EXTENSION, defaultCodecForContainer, ENCODER_SESSION_LIMIT, encodePngSequenceToFfv1, mixAudioTimeline, muxVideoWithAudio, resolveVideoEncoder, } from "./ffmpeg.js";
11
+ import { assertContainerCodec, assertFfmpegCapabilities, compositeTimelineFrame, compositeTimelineSegment, concatTimelineSegments, CONTAINER_EXTENSION, defaultCodecForContainer, encodeVideoSegmentDirect, ENCODER_SESSION_LIMIT, mixAudioTimeline, muxVideoWithAudio, resolveVideoEncoder, sceneLayerToCompositeLayer, startDirectSegmentEncoder, startDualPipeSegmentEncoder, startFfv1Encoder, } from "./ffmpeg.js";
12
+ import { createSemaphore } from "./concurrency.js";
13
+ import { CompositionSessionPool } from "./session-pool.js";
12
14
  import { inspectTranscriptClips } from "./transcripts.js";
15
+ import { startTiming } from "./timings.js";
16
+ import { evaluatePresentation } from "../timeline/keyframes.js";
17
+ import { transitionProgress } from "../timeline/transitions.js";
18
+ import { KEYFRAME_CHANNELS } from "../timeline/schema.js";
13
19
  import { planTimelineRender } from "../timeline/plan.js";
20
+ import { projectScene, transitionSpans } from "../timeline/scene.js";
14
21
  import { resolveProjectTimeline, } from "../timeline/resolve.js";
15
22
  export const MAX_TIMELINE_WORKERS = 16;
16
23
  function validateWorkers(value) {
@@ -30,11 +37,42 @@ function validateWorkers(value) {
30
37
  export function clampTimelineWorkers(workers, encoder) {
31
38
  return Math.min(workers, ENCODER_SESSION_LIMIT[encoder]);
32
39
  }
40
+ const MAX_CAPTURE_CONCURRENCY = 12;
41
+ /**
42
+ * Capture sessions are bounded independently from segment workers. A segment
43
+ * with two or three layers can overlap its captures without letting an
44
+ * eight-worker render open sixteen browsers.
45
+ */
46
+ function captureConcurrency(workers) {
47
+ const raw = process.env.FRAMEYARD_CAPTURE_CONCURRENCY;
48
+ if (raw !== undefined) {
49
+ const value = Number(raw);
50
+ if (Number.isInteger(value) && value > 0) {
51
+ return Math.min(value, 32);
52
+ }
53
+ }
54
+ return Math.min(workers * 2, MAX_CAPTURE_CONCURRENCY);
55
+ }
33
56
  function throwIfAborted(signal) {
34
57
  if (signal?.aborted) {
35
58
  throw new FrameyardError("RENDER_FAILED", "Operation cancelled.");
36
59
  }
37
60
  }
61
+ async function withCaptureSlot(limiter, signal, task) {
62
+ if (!limiter) {
63
+ throwIfAborted(signal);
64
+ return task();
65
+ }
66
+ throwIfAborted(signal);
67
+ const release = await limiter.acquire();
68
+ try {
69
+ throwIfAborted(signal);
70
+ return await task();
71
+ }
72
+ finally {
73
+ release();
74
+ }
75
+ }
38
76
  async function pathExists(filePath) {
39
77
  try {
40
78
  await stat(filePath);
@@ -136,19 +174,6 @@ function compositionDiagnostic(diagnostic, timelineId, clip, compositionId) {
136
174
  function clampVolume(value) {
137
175
  return Math.min(1, Math.max(0, value));
138
176
  }
139
- function visualSourceSize(clip, timeline) {
140
- const source = clip.source;
141
- if (source.kind === "composition") {
142
- return { width: source.metadata.width, height: source.metadata.height };
143
- }
144
- if (source.kind === "video" || source.kind === "image") {
145
- if (source.width === undefined || source.height === undefined) {
146
- throw new FrameyardError("TIMELINE_MEDIA_INVALID", `Media ${source.src} is missing width/height metadata for clip "${clip.id}".`);
147
- }
148
- return { width: source.width, height: source.height };
149
- }
150
- return { width: timeline.format.width, height: timeline.format.height };
151
- }
152
177
  function mapCompositionAudio(clip, audio, fps, window) {
153
178
  if (window.muted) {
154
179
  return null;
@@ -178,11 +203,16 @@ function mapCompositionAudio(clip, audio, fps, window) {
178
203
  trimStart: audio.trimStart + (effectiveStart - audio.startFrame) / fps,
179
204
  trimDuration: duration,
180
205
  volume: clampVolume(audio.volume * window.gain),
206
+ ...(window.envelopes.length > 0 ? { envelopes: window.envelopes } : {}),
207
+ // Without an explicit duration the source ends naturally; any remaining
208
+ // mapped window is silence, not an overrun.
209
+ ...(audio.trimDuration === undefined ? { allowShortSource: true } : {}),
181
210
  };
182
211
  }
183
212
  function collectTimelineAudio(timeline, window, clips, transcripts) {
184
213
  const fps = timeline.format.fps;
185
214
  const trackById = new Map(timeline.tracks.map((track) => [track.id, track]));
215
+ const spansByClip = transitionSpans(timeline);
186
216
  for (const clip of timeline.clips) {
187
217
  const source = clip.source;
188
218
  const mappedStart = clip.start + window.frameOffset;
@@ -194,6 +224,13 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
194
224
  }
195
225
  const clipGain = window.gain * (clip.audio?.gain ?? 1);
196
226
  const clipMuted = window.muted || (clip.audio?.muted ?? false);
227
+ const ownEnvelopes = (spansByClip.get(clip.id) ?? []).map((span) => ({
228
+ startFrame: span.start + window.frameOffset,
229
+ endFrame: span.end + window.frameOffset,
230
+ ...(span.easing !== undefined ? { easing: span.easing } : {}),
231
+ role: span.role,
232
+ }));
233
+ const envelopes = ownEnvelopes.length > 0 ? [...window.envelopes, ...ownEnvelopes] : window.envelopes;
197
234
  if (source.kind === "composition") {
198
235
  for (const transcript of source.transcriptClips) {
199
236
  transcripts.set(`${transcript.src}\u0000${transcript.words}`, transcript);
@@ -202,13 +239,13 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
202
239
  continue;
203
240
  }
204
241
  for (const audio of source.audioClips) {
205
- const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain });
242
+ const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain, envelopes });
206
243
  if (mapped) {
207
244
  clips.push(mapped);
208
245
  }
209
246
  }
210
247
  for (const audio of source.embeddedAudioClips) {
211
- const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain });
248
+ const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain, envelopes });
212
249
  if (mapped) {
213
250
  clips.push({ ...mapped, allowSilent: true });
214
251
  }
@@ -222,6 +259,7 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
222
259
  endFrame,
223
260
  gain: clipGain,
224
261
  muted: clipMuted,
262
+ envelopes,
225
263
  }, clips, transcripts);
226
264
  continue;
227
265
  }
@@ -233,6 +271,7 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
233
271
  trimStart: clip.sourceIn / fps + (startFrame - mappedStart) / fps,
234
272
  trimDuration: (endFrame - startFrame) / fps,
235
273
  volume: clampVolume(clipGain),
274
+ ...(envelopes.length > 0 ? { envelopes } : {}),
236
275
  });
237
276
  }
238
277
  continue;
@@ -248,6 +287,7 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
248
287
  trimStart: clip.sourceIn / fps + (startFrame - mappedStart) / fps,
249
288
  trimDuration: (endFrame - startFrame) / fps,
250
289
  volume: clampVolume(clipGain * (track.gain ?? 1)),
290
+ ...(envelopes.length > 0 ? { envelopes } : {}),
251
291
  });
252
292
  }
253
293
  }
@@ -255,7 +295,7 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
255
295
  function buildAudioClips(timeline) {
256
296
  const clips = [];
257
297
  const transcripts = new Map();
258
- collectTimelineAudio(timeline, { frameOffset: 0, startFrame: 0, endFrame: timeline.durationInFrames, gain: 1, muted: false }, clips, transcripts);
298
+ collectTimelineAudio(timeline, { frameOffset: 0, startFrame: 0, endFrame: timeline.durationInFrames, gain: 1, muted: false, envelopes: [] }, clips, transcripts);
259
299
  return { clips, transcripts: [...transcripts.values()] };
260
300
  }
261
301
  /** Resolved timeline audio for preview surfaces. */
@@ -263,7 +303,7 @@ export function resolveTimelineAudio(timeline) {
263
303
  const { clips, transcripts } = buildAudioClips(timeline);
264
304
  return {
265
305
  clips: clips.map((clip) => {
266
- const { allowSilent: _allowSilent, ...resolved } = clip;
306
+ const { allowSilent: _allowSilent, allowShortSource: _allowShortSource, ...resolved } = clip;
267
307
  return resolved;
268
308
  }),
269
309
  transcripts,
@@ -321,8 +361,13 @@ async function captureStaticCompositionLayer(options) {
321
361
  throw error;
322
362
  }
323
363
  }
324
- async function captureCompositionLayer(options) {
325
- const { project, timeline, clip, source, segment, layerIndex, serverUrl, segmentDir, infoCache } = options;
364
+ /**
365
+ * Opens a composition session, captures every frame of the segment, and hands
366
+ * each PNG to `writeFrame`. The caller owns the encoder lifecycle so the same
367
+ * loop can feed an FFV1 intermediate or a direct delivery segment.
368
+ */
369
+ async function captureCompositionFrames(options, writeFrame) {
370
+ const { project, timeline, clip, source, segment, layerIndex, serverUrl, infoCache } = options;
326
371
  const propsKey = JSON.stringify(clip.props ?? null);
327
372
  const cacheKey = `${source.entry}\u0000${propsKey}`;
328
373
  let validation = infoCache.get(cacheKey);
@@ -342,38 +387,293 @@ async function captureCompositionLayer(options) {
342
387
  if (!layer) {
343
388
  throw new FrameyardError("INTERNAL", `Missing layer ${layerIndex} for segment ${segment.index}.`);
344
389
  }
345
- const prefix = `comp-${layerIndex}`;
346
- const session = await openCompositionSession({
390
+ const sessionTiming = startTiming("timeline.layer.session", {
391
+ entry: source.entry,
392
+ layer: layerIndex,
393
+ segment: segment.index,
394
+ });
395
+ const sessionKey = JSON.stringify([source.entry, clip.props ?? null, true]);
396
+ const openOptions = {
347
397
  serverUrl,
348
398
  entry: source.entry,
349
399
  props: clip.props,
350
400
  info: validation.info,
351
401
  staticDiagnostics: validation.diagnostics,
352
402
  transparentBackground: true,
353
- });
403
+ };
404
+ const lease = options.sessionPool
405
+ ? await options.sessionPool.acquire(sessionKey, () => openCompositionSession(openOptions))
406
+ : await (async () => {
407
+ const session = await openCompositionSession(openOptions);
408
+ return {
409
+ session,
410
+ reused: false,
411
+ release: () => session.close(),
412
+ discard: () => session.close(),
413
+ };
414
+ })();
415
+ sessionTiming.finish({ reused: lease.reused });
416
+ const session = lease.session;
417
+ let healthy = false;
354
418
  const diagnostics = [];
419
+ const captureTiming = startTiming("timeline.layer.capture_frames", {
420
+ entry: source.entry,
421
+ layer: layerIndex,
422
+ segment: segment.index,
423
+ frames: segment.frames,
424
+ });
425
+ let seekMs = 0;
426
+ let captureMs = 0;
427
+ let writeMs = 0;
428
+ let pngBytes = 0;
355
429
  try {
356
430
  for (let offset = 0; offset < segment.frames; offset += 1) {
357
431
  throwIfAborted(options.signal);
432
+ const seekStarted = performance.now();
358
433
  await session.seek(layer.sourceStart + offset);
434
+ seekMs += performance.now() - seekStarted;
435
+ const captureStarted = performance.now();
359
436
  const png = await session.capture();
360
- await writeFile(path.join(segmentDir, `${prefix}-${String(offset).padStart(4, "0")}.png`), png);
437
+ captureMs += performance.now() - captureStarted;
438
+ pngBytes += png.length;
439
+ const writeStarted = performance.now();
440
+ await writeFrame(png);
441
+ writeMs += performance.now() - writeStarted;
361
442
  }
362
443
  for (const diagnostic of await session.readDiagnostics()) {
363
444
  diagnostics.push(compositionDiagnostic(diagnostic, timeline.id, clip, source.id));
364
445
  }
446
+ healthy = true;
365
447
  }
366
448
  finally {
367
- await session.close();
449
+ captureTiming.finish({
450
+ seekMs: Number(seekMs.toFixed(1)),
451
+ captureMs: Number(captureMs.toFixed(1)),
452
+ writeMs: Number(writeMs.toFixed(1)),
453
+ pngBytes,
454
+ });
455
+ if (healthy) {
456
+ await lease.release();
457
+ }
458
+ else {
459
+ await lease.discard();
460
+ }
368
461
  }
369
- const outputPath = path.join(segmentDir, `${prefix}.mkv`);
370
- await encodePngSequenceToFfv1({
371
- pattern: path.join(segmentDir, `${prefix}-%04d.png`),
372
- outputPath,
373
- fps: timeline.format.fps,
374
- signal: options.signal,
462
+ return diagnostics;
463
+ }
464
+ async function captureCompositionLayer(options) {
465
+ const prefix = `comp-${options.layerIndex}`;
466
+ const outputPath = path.join(options.segmentDir, `${prefix}.mkv`);
467
+ const ffv1Timing = startTiming("timeline.layer.ffv1", {
468
+ entry: options.source.entry,
469
+ layer: options.layerIndex,
470
+ segment: options.segment.index,
375
471
  });
376
- return { path: outputPath, diagnostics };
472
+ let encoder;
473
+ try {
474
+ encoder = await startFfv1Encoder({
475
+ outputPath,
476
+ frames: options.segment.frames,
477
+ fps: options.timeline.format.fps,
478
+ ...(options.signal ? { signal: options.signal } : {}),
479
+ });
480
+ }
481
+ catch (error) {
482
+ ffv1Timing.finish();
483
+ throw error;
484
+ }
485
+ try {
486
+ const diagnostics = await captureCompositionFrames(options, (png) => encoder.writeFrame(png));
487
+ await encoder.finish();
488
+ return { path: outputPath, diagnostics };
489
+ }
490
+ catch (error) {
491
+ await encoder.abort();
492
+ throw error;
493
+ }
494
+ finally {
495
+ ffv1Timing.finish();
496
+ }
497
+ }
498
+ function isFullFrameComposition(layer, timeline) {
499
+ if (layer.clip.source.kind !== "composition" || layer.capture !== "frames") {
500
+ return false;
501
+ }
502
+ if (layer.transform.width !== undefined || layer.transform.height !== undefined) {
503
+ return false;
504
+ }
505
+ if (layer.sourceWidth !== timeline.format.width || layer.sourceHeight !== timeline.format.height) {
506
+ return false;
507
+ }
508
+ const { x, y, scale, rotation, opacity } = layer.transform;
509
+ if (x !== 0 || y !== 0 || scale !== 1 || rotation !== 0 || opacity !== 1) {
510
+ return false;
511
+ }
512
+ return layer.blend === "normal" && layer.transition === undefined && layer.keyframes === undefined;
513
+ }
514
+ function canEncodeDirectly(options) {
515
+ if (options.background === "transparent" || options.segment.layers.length !== 1) {
516
+ return false;
517
+ }
518
+ const layer = options.segment.layers[0];
519
+ return layer !== undefined && isFullFrameComposition(layer, options.timeline);
520
+ }
521
+ function isStaticCompositionOverlay(layer) {
522
+ if (layer.clip.source.kind !== "composition" || layer.capture !== "frames") {
523
+ return false;
524
+ }
525
+ const { scale, rotation, opacity } = layer.transform;
526
+ if (scale !== 1 || rotation !== 0 || opacity !== 1) {
527
+ return false;
528
+ }
529
+ return layer.blend === "normal" && layer.transition === undefined && layer.keyframes === undefined;
530
+ }
531
+ function canEncodeVideoDirectly(options) {
532
+ if (process.env.FRAMEYARD_DISABLE_VIDEO_DIRECT === "1") {
533
+ return false;
534
+ }
535
+ if (options.background === "transparent" || options.segment.layers.length !== 1) {
536
+ return false;
537
+ }
538
+ const layer = options.segment.layers[0];
539
+ if (!layer || layer.clip.source.kind !== "video" || layer.capture !== "video") {
540
+ return false;
541
+ }
542
+ if (layer.sourceWidth !== options.timeline.format.width || layer.sourceHeight !== options.timeline.format.height) {
543
+ return false;
544
+ }
545
+ const { x, y, scale, rotation, opacity, width, height } = layer.transform;
546
+ if (width !== undefined ||
547
+ height !== undefined ||
548
+ x !== 0 ||
549
+ y !== 0 ||
550
+ scale !== 1 ||
551
+ rotation !== 0 ||
552
+ opacity !== 1) {
553
+ return false;
554
+ }
555
+ return layer.blend === "normal" && layer.transition === undefined && layer.keyframes === undefined;
556
+ }
557
+ function canEncodeDualPipe(options) {
558
+ // FFmpeg reads the second image pipe through fd 3; keep the conservative
559
+ // fallback on hosts where extra stdio pipes are not portable.
560
+ if (process.platform === "win32" ||
561
+ process.env.FRAMEYARD_DISABLE_DUAL_PIPE === "1" ||
562
+ options.background === "transparent" ||
563
+ options.segment.layers.length !== 2) {
564
+ return false;
565
+ }
566
+ const base = options.segment.layers[0];
567
+ const overlay = options.segment.layers[1];
568
+ return (base !== undefined &&
569
+ overlay !== undefined &&
570
+ isFullFrameComposition(base, options.timeline) &&
571
+ isStaticCompositionOverlay(overlay));
572
+ }
573
+ async function captureDirectCompositionLayer(options) {
574
+ const timing = startTiming("timeline.layer.composition", {
575
+ entry: options.source.entry,
576
+ layer: options.layerIndex,
577
+ segment: options.segment.index,
578
+ });
579
+ let encoder;
580
+ try {
581
+ encoder = await startDirectSegmentEncoder({
582
+ outputPath: options.outputPath,
583
+ width: options.timeline.format.width,
584
+ height: options.timeline.format.height,
585
+ fps: options.timeline.format.fps,
586
+ frames: options.segment.frames,
587
+ background: options.background,
588
+ codec: options.codec,
589
+ container: options.container,
590
+ ...(options.encoderName !== undefined ? { encoder: options.encoderName } : {}),
591
+ ...(options.signal ? { signal: options.signal } : {}),
592
+ });
593
+ }
594
+ catch (error) {
595
+ timing.finish();
596
+ throw error;
597
+ }
598
+ try {
599
+ const diagnostics = await captureCompositionFrames(options, (png) => encoder.writeFrame(png));
600
+ await encoder.finish();
601
+ return { path: options.outputPath, diagnostics };
602
+ }
603
+ catch (error) {
604
+ await encoder.abort();
605
+ throw error;
606
+ }
607
+ finally {
608
+ timing.finish();
609
+ }
610
+ }
611
+ async function captureTwoLayerDirectComposition(options) {
612
+ const overlayLayer = options.segment.layers[1];
613
+ if (!overlayLayer) {
614
+ throw new FrameyardError("INTERNAL", "Dual-pipe capture requires an overlay layer.");
615
+ }
616
+ const overlay = sceneLayerToCompositeLayer(overlayLayer, { path: "pipe:3", input: "sequence" });
617
+ const encoder = await startDualPipeSegmentEncoder({
618
+ outputPath: options.outputPath,
619
+ width: options.timeline.format.width,
620
+ height: options.timeline.format.height,
621
+ fps: options.timeline.format.fps,
622
+ frames: options.segment.frames,
623
+ background: options.background,
624
+ codec: options.codec,
625
+ container: options.container,
626
+ overlay,
627
+ ...(options.encoderName !== undefined ? { encoder: options.encoderName } : {}),
628
+ ...(options.signal ? { signal: options.signal } : {}),
629
+ });
630
+ const tasks = options.segment.layers.map(async (layer, layerIndex) => {
631
+ if (layer.clip.source.kind !== "composition") {
632
+ throw new FrameyardError("INTERNAL", "Dual-pipe capture requires composition layers.");
633
+ }
634
+ const source = layer.clip.source;
635
+ return withCaptureSlot(options.captureLimiter, options.signal, async () => {
636
+ const timing = startTiming("timeline.layer.composition", {
637
+ entry: source.entry,
638
+ layer: layerIndex,
639
+ segment: options.segment.index,
640
+ });
641
+ try {
642
+ return await captureCompositionFrames({
643
+ project: options.project,
644
+ timeline: options.timeline,
645
+ clip: layer.clip,
646
+ source,
647
+ segment: options.segment,
648
+ layerIndex,
649
+ serverUrl: options.serverUrl,
650
+ infoCache: options.infoCache,
651
+ ...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
652
+ ...(options.signal ? { signal: options.signal } : {}),
653
+ }, (png) => encoder.writeFrame(layerIndex, png));
654
+ }
655
+ finally {
656
+ timing.finish();
657
+ }
658
+ });
659
+ });
660
+ const settled = await Promise.allSettled(tasks);
661
+ const diagnostics = [];
662
+ for (const result of settled) {
663
+ if (result.status === "rejected") {
664
+ await encoder.abort();
665
+ throw result.reason;
666
+ }
667
+ diagnostics.push(...result.value);
668
+ }
669
+ try {
670
+ await encoder.finish();
671
+ }
672
+ catch (error) {
673
+ await encoder.abort();
674
+ throw error;
675
+ }
676
+ return { path: options.outputPath, diagnostics };
377
677
  }
378
678
  async function renderNestedTimelineRange(options) {
379
679
  if (options.depth > 32) {
@@ -395,6 +695,8 @@ async function renderNestedTimelineRange(options) {
395
695
  scratchRoot: options.scratchRoot,
396
696
  infoCache: options.infoCache,
397
697
  staticCache: options.staticCache,
698
+ ...(options.captureLimiter !== undefined ? { captureLimiter: options.captureLimiter } : {}),
699
+ ...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
398
700
  codec: "ffv1",
399
701
  background: options.timeline.background ?? "transparent",
400
702
  prefix,
@@ -424,36 +726,118 @@ async function processTimelineSegment(options) {
424
726
  const prefix = options.prefix ?? "timeline";
425
727
  const depth = options.depth ?? 0;
426
728
  const segmentDir = path.join(scratchRoot, `${prefix}-seg-${String(segment.index).padStart(4, "0")}`);
729
+ const segmentTiming = startTiming("timeline.segment", {
730
+ segment: segment.index,
731
+ start: segment.start,
732
+ frames: segment.frames,
733
+ layers: segment.layers.length,
734
+ });
735
+ const extension = codec === "ffv1" ? "mkv" : CONTAINER_EXTENSION[options.container ?? "mp4"];
736
+ const segmentPath = path.join(scratchRoot, `${prefix}-segment-${String(segment.index).padStart(4, "0")}.${extension}`);
737
+ if (codec !== "ffv1" && canEncodeDirectly({ segment, timeline, background })) {
738
+ const layer = segment.layers[0];
739
+ if (!layer) {
740
+ throw new FrameyardError("INTERNAL", "Direct segment capture requires one composition layer.");
741
+ }
742
+ const clip = layer.clip;
743
+ if (clip.source.kind !== "composition") {
744
+ throw new FrameyardError("INTERNAL", "Direct segment capture requires one composition layer.");
745
+ }
746
+ const source = clip.source;
747
+ const directTiming = startTiming("timeline.segment.direct", {
748
+ segment: segment.index,
749
+ frames: segment.frames,
750
+ codec,
751
+ container: options.container ?? "mp4",
752
+ });
753
+ const captured = await withCaptureSlot(options.captureLimiter, options.signal, () => captureDirectCompositionLayer({
754
+ project,
755
+ timeline,
756
+ clip,
757
+ source,
758
+ segment,
759
+ layerIndex: 0,
760
+ serverUrl,
761
+ infoCache,
762
+ outputPath: segmentPath,
763
+ background,
764
+ codec,
765
+ container: options.container ?? "mp4",
766
+ ...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
767
+ ...(options.encoder !== undefined ? { encoderName: options.encoder } : {}),
768
+ ...(options.signal ? { signal: options.signal } : {}),
769
+ }));
770
+ directTiming.finish();
771
+ segmentTiming.finish();
772
+ return { path: segmentPath, diagnostics: captured.diagnostics };
773
+ }
774
+ if (codec !== "ffv1" && canEncodeDualPipe({ segment, timeline, background })) {
775
+ const directTiming = startTiming("timeline.segment.direct", {
776
+ segment: segment.index,
777
+ frames: segment.frames,
778
+ codec,
779
+ container: options.container ?? "mp4",
780
+ layers: 2,
781
+ });
782
+ const captured = await captureTwoLayerDirectComposition({
783
+ project,
784
+ timeline,
785
+ segment,
786
+ serverUrl,
787
+ infoCache,
788
+ outputPath: segmentPath,
789
+ background,
790
+ codec,
791
+ container: options.container ?? "mp4",
792
+ ...(options.captureLimiter !== undefined ? { captureLimiter: options.captureLimiter } : {}),
793
+ ...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
794
+ ...(options.encoder !== undefined ? { encoderName: options.encoder } : {}),
795
+ ...(options.signal ? { signal: options.signal } : {}),
796
+ });
797
+ directTiming.finish();
798
+ segmentTiming.finish();
799
+ return { path: segmentPath, diagnostics: captured.diagnostics };
800
+ }
801
+ if (codec !== "ffv1" && canEncodeVideoDirectly({ segment, timeline, background })) {
802
+ const layer = segment.layers[0];
803
+ if (!layer || layer.clip.source.kind !== "video") {
804
+ throw new FrameyardError("INTERNAL", "Direct video capture requires one video layer.");
805
+ }
806
+ const source = layer.clip.source;
807
+ const directTiming = startTiming("timeline.segment.direct", {
808
+ segment: segment.index,
809
+ frames: segment.frames,
810
+ codec,
811
+ container: options.container ?? "mp4",
812
+ layers: 1,
813
+ kind: "video",
814
+ });
815
+ await encodeVideoSegmentDirect({
816
+ inputPath: source.absolutePath,
817
+ outputPath: segmentPath,
818
+ sourceStartFrame: layer.sourceStart,
819
+ frames: segment.frames,
820
+ fps: timeline.format.fps,
821
+ codec,
822
+ container: options.container ?? "mp4",
823
+ ...(options.encoder !== undefined ? { encoder: options.encoder } : {}),
824
+ ...(options.signal ? { signal: options.signal } : {}),
825
+ });
826
+ directTiming.finish();
827
+ segmentTiming.finish();
828
+ return { path: segmentPath, diagnostics: [] };
829
+ }
427
830
  await mkdir(segmentDir, { recursive: true });
428
831
  const diagnostics = [];
429
832
  const layers = [];
430
- for (const [layerIndex, layer] of segment.layers.entries()) {
833
+ const layerTasks = segment.layers.map(async (layer, layerIndex) => {
431
834
  throwIfAborted(options.signal);
432
835
  const clip = layer.clip;
433
836
  const source = clip.source;
434
- const transform = clip.transform ?? {
435
- fit: "contain",
436
- x: 0,
437
- y: 0,
438
- scale: 1,
439
- rotation: 0,
440
- opacity: 1,
441
- };
442
- const base = {
443
- fit: transform.fit,
444
- x: transform.x,
445
- y: transform.y,
446
- ...(transform.width !== undefined && transform.height !== undefined
447
- ? { width: transform.width, height: transform.height }
448
- : {}),
449
- scale: transform.scale,
450
- rotation: transform.rotation,
451
- opacity: transform.opacity,
452
- blend: clip.blend ?? "normal",
453
- };
454
837
  if (source.kind === "composition") {
455
- if (source.metadata.static === true) {
456
- const captured = await captureStaticCompositionLayer({
838
+ if (layer.capture === "still") {
839
+ const timing = startTiming("timeline.layer.static", { layer: layerIndex, segment: segment.index });
840
+ const captured = await withCaptureSlot(options.captureLimiter, options.signal, () => captureStaticCompositionLayer({
457
841
  project,
458
842
  timeline,
459
843
  clip,
@@ -463,18 +847,20 @@ async function processTimelineSegment(options) {
463
847
  infoCache,
464
848
  staticCache: options.staticCache,
465
849
  ...(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;
850
+ }));
851
+ timing.finish();
852
+ return {
853
+ layerIndex,
854
+ compositeLayer: sceneLayerToCompositeLayer(layer, { path: captured.path, input: "image" }),
855
+ diagnostics: captured.diagnostics,
856
+ };
476
857
  }
477
- const captured = await captureCompositionLayer({
858
+ const timing = startTiming("timeline.layer.composition", {
859
+ entry: source.entry,
860
+ layer: layerIndex,
861
+ segment: segment.index,
862
+ });
863
+ const captured = await withCaptureSlot(options.captureLimiter, options.signal, () => captureCompositionLayer({
478
864
  project,
479
865
  timeline,
480
866
  clip,
@@ -484,31 +870,29 @@ async function processTimelineSegment(options) {
484
870
  serverUrl,
485
871
  segmentDir,
486
872
  infoCache,
873
+ ...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
487
874
  ...(options.signal ? { signal: options.signal } : {}),
488
- });
489
- diagnostics.push(...captured.diagnostics);
490
- layers.push({
491
- kind: "composition",
492
- path: captured.path,
493
- sourceWidth: source.metadata.width,
494
- sourceHeight: source.metadata.height,
495
- ...base,
496
- });
497
- continue;
875
+ }));
876
+ timing.finish();
877
+ return {
878
+ layerIndex,
879
+ compositeLayer: sceneLayerToCompositeLayer(layer, { path: captured.path, input: "sequence" }),
880
+ diagnostics: captured.diagnostics,
881
+ };
498
882
  }
499
883
  if (source.kind === "video" || source.kind === "image") {
500
- const size = visualSourceSize(clip, timeline);
501
- layers.push({
502
- kind: source.kind,
503
- path: source.absolutePath,
504
- sourceWidth: size.width,
505
- sourceHeight: size.height,
506
- ...base,
507
- ...(source.kind === "video" ? { sourceStartFrame: layer.sourceStart } : {}),
508
- });
509
- continue;
884
+ return {
885
+ layerIndex,
886
+ compositeLayer: sceneLayerToCompositeLayer(layer, {
887
+ path: source.absolutePath,
888
+ input: source.kind,
889
+ ...(source.kind === "video" ? { sourceStartFrame: layer.sourceStart } : {}),
890
+ }),
891
+ diagnostics: [],
892
+ };
510
893
  }
511
894
  if (source.kind === "timeline") {
895
+ const timing = startTiming("timeline.layer.nested", { layer: layerIndex, segment: segment.index });
512
896
  const nested = await renderNestedTimelineRange({
513
897
  project,
514
898
  timeline: source.timeline,
@@ -518,23 +902,39 @@ async function processTimelineSegment(options) {
518
902
  scratchRoot,
519
903
  infoCache,
520
904
  staticCache: options.staticCache,
905
+ ...(options.captureLimiter !== undefined ? { captureLimiter: options.captureLimiter } : {}),
906
+ ...(options.sessionPool !== undefined ? { sessionPool: options.sessionPool } : {}),
521
907
  depth: depth + 1,
522
908
  ...(options.signal ? { signal: options.signal } : {}),
523
909
  });
524
- diagnostics.push(...nested.diagnostics);
525
- layers.push({
526
- kind: "timeline",
527
- path: nested.path,
528
- sourceWidth: source.timeline.format.width,
529
- sourceHeight: source.timeline.format.height,
530
- ...base,
531
- });
532
- continue;
910
+ timing.finish();
911
+ return {
912
+ layerIndex,
913
+ compositeLayer: sceneLayerToCompositeLayer(layer, { path: nested.path, input: "sequence" }),
914
+ diagnostics: nested.diagnostics,
915
+ };
533
916
  }
534
917
  throw new FrameyardError("TIMELINE_INVALID", `Unsupported visual source for clip "${clip.id}".`);
918
+ });
919
+ const settled = await Promise.allSettled(layerTasks);
920
+ const capturedLayers = [];
921
+ for (const result of settled) {
922
+ if (result.status === "rejected") {
923
+ throw result.reason;
924
+ }
925
+ capturedLayers.push(result.value);
535
926
  }
536
- const extension = codec === "ffv1" ? "mkv" : CONTAINER_EXTENSION[options.container ?? "mp4"];
537
- const segmentPath = path.join(scratchRoot, `${prefix}-segment-${String(segment.index).padStart(4, "0")}.${extension}`);
927
+ capturedLayers.sort((left, right) => left.layerIndex - right.layerIndex);
928
+ for (const captured of capturedLayers) {
929
+ diagnostics.push(...captured.diagnostics);
930
+ layers.push(captured.compositeLayer);
931
+ }
932
+ const compositeTiming = startTiming("timeline.segment.composite", {
933
+ segment: segment.index,
934
+ frames: segment.frames,
935
+ layers: layers.length,
936
+ codec,
937
+ });
538
938
  await compositeTimelineSegment({
539
939
  outputPath: segmentPath,
540
940
  width: timeline.format.width,
@@ -544,13 +944,63 @@ async function processTimelineSegment(options) {
544
944
  background,
545
945
  layers,
546
946
  codec,
947
+ frameOffset: segment.start,
547
948
  ...(options.container !== undefined ? { container: options.container } : {}),
548
949
  ...(options.encoder !== undefined ? { encoder: options.encoder } : {}),
549
950
  ...(options.signal ? { signal: options.signal } : {}),
550
951
  });
952
+ compositeTiming.finish();
953
+ const cleanupTiming = startTiming("timeline.segment.cleanup", { segment: segment.index });
551
954
  await rm(segmentDir, { recursive: true, force: true });
955
+ cleanupTiming.finish();
956
+ segmentTiming.finish();
552
957
  return { path: segmentPath, diagnostics };
553
958
  }
959
+ function checkKeyframeEvaluation(timeline, segments) {
960
+ for (const segment of segments) {
961
+ for (const layer of segment.layers) {
962
+ const keyframes = layer.keyframes;
963
+ if (!keyframes) {
964
+ continue;
965
+ }
966
+ for (let frame = segment.start; frame < segment.end; frame += 1) {
967
+ const values = evaluatePresentation(layer.transform, keyframes, frame - layer.clip.start);
968
+ for (const channel of KEYFRAME_CHANNELS) {
969
+ if (!Number.isFinite(values[channel])) {
970
+ throw new FrameyardError("TIMELINE_CLIP_INVALID", `Clip "${layer.clip.id}" keyframes.${channel} produced a non-finite value at timeline frame ${frame}.`, {
971
+ details: {
972
+ timeline: timeline.id,
973
+ clip: layer.clip.id,
974
+ track: layer.clip.track,
975
+ channel,
976
+ frame: frame - layer.clip.start,
977
+ timelineFrame: frame,
978
+ },
979
+ });
980
+ }
981
+ }
982
+ }
983
+ }
984
+ }
985
+ }
986
+ function checkTransitionEvaluation(timeline) {
987
+ for (const transition of timeline.transitions) {
988
+ for (let frame = transition.start; frame < transition.end; frame += 1) {
989
+ const progress = transitionProgress(transition, frame);
990
+ if (!Number.isFinite(progress)) {
991
+ throw new FrameyardError("TIMELINE_TRANSITION_INVALID", `Transition "${transition.from}" → "${transition.to}" produced a non-finite progress at timeline frame ${frame}.`, {
992
+ details: {
993
+ timeline: timeline.id,
994
+ track: transition.track,
995
+ from: transition.from,
996
+ to: transition.to,
997
+ timelineFrame: frame,
998
+ },
999
+ });
1000
+ }
1001
+ }
1002
+ }
1003
+ }
554
1004
  async function checkResolvedTimelineRange(options) {
555
1005
  if (options.context.depth > 32) {
556
1006
  throw new FrameyardError("TIMELINE_INVALID", "Nested timeline depth exceeds 32.");
@@ -558,6 +1008,8 @@ async function checkResolvedTimelineRange(options) {
558
1008
  const { timeline, startFrame, endFrame, context } = options;
559
1009
  const plan = planTimelineRender(timeline, { startFrame, endFrame });
560
1010
  const diagnostics = [];
1011
+ checkKeyframeEvaluation(timeline, plan.segments);
1012
+ checkTransitionEvaluation(timeline);
561
1013
  const targets = new Map();
562
1014
  for (const segment of plan.segments) {
563
1015
  for (const layer of segment.layers) {
@@ -664,7 +1116,7 @@ export async function checkTimeline(options) {
664
1116
  const startedAt = performance.now();
665
1117
  const projectTimeline = options.project.timelines.get(options.timelineId);
666
1118
  if (!projectTimeline) {
667
- throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
1119
+ throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in fy.config.js.`);
668
1120
  }
669
1121
  const resolved = await resolveProjectTimeline({
670
1122
  project: options.project,
@@ -740,6 +1192,7 @@ export async function checkTimeline(options) {
740
1192
  framesChecked: timeline.durationInFrames,
741
1193
  tracks: timeline.tracks.length,
742
1194
  clips: timeline.clips.length,
1195
+ transitions: timeline.transitions.length,
743
1196
  audio: { clips: audioInspection.clips.length, details: audioInspection.clips },
744
1197
  transcripts: {
745
1198
  clips: transcripts.clips.length,
@@ -754,36 +1207,12 @@ async function renderResolvedTimelineFrame(options) {
754
1207
  throw new FrameyardError("TIMELINE_INVALID", "Nested timeline depth exceeds 32.");
755
1208
  }
756
1209
  const { timeline, frame, context } = options;
757
- const plan = planTimelineRender(timeline, { startFrame: frame, endFrame: frame + 1 });
758
- const segment = plan.segments[0];
759
- if (!segment) {
760
- throw new FrameyardError("FRAME_OUT_OF_RANGE", `Frame ${frame} does not belong to a timeline segment.`);
761
- }
1210
+ const scene = projectScene(timeline, frame, frame + 1);
762
1211
  const diagnostics = [];
763
1212
  const layers = [];
764
- for (const [layerIndex, layer] of segment.layers.entries()) {
1213
+ for (const [layerIndex, layer] of scene.layers.entries()) {
765
1214
  const clip = layer.clip;
766
1215
  const source = clip.source;
767
- const transform = clip.transform ?? {
768
- fit: "contain",
769
- x: 0,
770
- y: 0,
771
- scale: 1,
772
- rotation: 0,
773
- opacity: 1,
774
- };
775
- const base = {
776
- fit: transform.fit,
777
- x: transform.x,
778
- y: transform.y,
779
- ...(transform.width !== undefined && transform.height !== undefined
780
- ? { width: transform.width, height: transform.height }
781
- : {}),
782
- scale: transform.scale,
783
- rotation: transform.rotation,
784
- opacity: transform.opacity,
785
- blend: clip.blend ?? "normal",
786
- };
787
1216
  if (source.kind === "composition") {
788
1217
  const validation = await validateHtmlComposition({
789
1218
  projectRoot: context.project.root,
@@ -814,25 +1243,15 @@ async function renderResolvedTimelineFrame(options) {
814
1243
  finally {
815
1244
  await session.close();
816
1245
  }
817
- layers.push({
818
- kind: "image",
819
- path: framePath,
820
- sourceWidth: source.metadata.width,
821
- sourceHeight: source.metadata.height,
822
- ...base,
823
- });
1246
+ layers.push(sceneLayerToCompositeLayer(layer, { path: framePath, input: "image" }));
824
1247
  continue;
825
1248
  }
826
1249
  if (source.kind === "video" || source.kind === "image") {
827
- const size = visualSourceSize(clip, timeline);
828
- layers.push({
829
- kind: source.kind,
1250
+ layers.push(sceneLayerToCompositeLayer(layer, {
830
1251
  path: source.absolutePath,
831
- sourceWidth: size.width,
832
- sourceHeight: size.height,
833
- ...base,
1252
+ input: source.kind,
834
1253
  ...(source.kind === "video" ? { sourceStartFrame: layer.sourceStart } : {}),
835
- });
1254
+ }));
836
1255
  continue;
837
1256
  }
838
1257
  if (source.kind === "timeline") {
@@ -845,13 +1264,7 @@ async function renderResolvedTimelineFrame(options) {
845
1264
  context: { ...context, depth: context.depth + 1 },
846
1265
  });
847
1266
  diagnostics.push(...nestedDiagnostics);
848
- layers.push({
849
- kind: "image",
850
- path: nestedPath,
851
- sourceWidth: source.timeline.format.width,
852
- sourceHeight: source.timeline.format.height,
853
- ...base,
854
- });
1267
+ layers.push(sceneLayerToCompositeLayer(layer, { path: nestedPath, input: "image" }));
855
1268
  continue;
856
1269
  }
857
1270
  throw new FrameyardError("TIMELINE_INVALID", `Unsupported visual source for clip "${clip.id}".`);
@@ -863,6 +1276,7 @@ async function renderResolvedTimelineFrame(options) {
863
1276
  fps: timeline.format.fps,
864
1277
  background: options.background,
865
1278
  layers,
1279
+ frameOffset: frame,
866
1280
  ...(context.signal ? { signal: context.signal } : {}),
867
1281
  });
868
1282
  return diagnostics;
@@ -895,7 +1309,7 @@ export async function captureTimelineStills(options) {
895
1309
  const startedAt = performance.now();
896
1310
  const projectTimeline = options.project.timelines.get(options.timelineId);
897
1311
  if (!projectTimeline) {
898
- throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
1312
+ throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in fy.config.js.`);
899
1313
  }
900
1314
  const resolved = await resolveProjectTimeline({
901
1315
  project: options.project,
@@ -1000,20 +1414,25 @@ export async function renderTimeline(options) {
1000
1414
  const requestedWorkers = validateWorkers(options.workers);
1001
1415
  const overwrite = options.overwrite ?? false;
1002
1416
  const startedAt = performance.now();
1417
+ const renderTiming = startTiming("timeline.render", { timeline: options.timelineId });
1003
1418
  await ensureOutputAvailable(options.outPath, overwrite);
1004
1419
  await assertFfmpegCapabilities(options.signal, codec);
1005
1420
  const encoder = await resolveVideoEncoder(codec, options.encoder, options.signal);
1006
1421
  const workers = clampTimelineWorkers(requestedWorkers, encoder);
1422
+ const captureLimiter = createSemaphore(captureConcurrency(workers));
1423
+ const sessionPool = new CompositionSessionPool({ maxIdle: Math.max(2, workers) });
1007
1424
  const projectTimeline = options.project.timelines.get(options.timelineId);
1008
1425
  if (!projectTimeline) {
1009
- throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
1426
+ throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in fy.config.js.`);
1010
1427
  }
1428
+ const resolveTiming = startTiming("timeline.resolve", { timeline: options.timelineId });
1011
1429
  const resolved = await resolveProjectTimeline({
1012
1430
  project: options.project,
1013
1431
  timeline: projectTimeline,
1014
1432
  ...(options.signal ? { signal: options.signal } : {}),
1015
1433
  ...(options.propsOverrides ? { propsOverrides: options.propsOverrides } : {}),
1016
1434
  });
1435
+ resolveTiming.finish({ clips: resolved.timeline?.clips.length ?? 0, diagnostics: resolved.diagnostics.length });
1017
1436
  if (!resolved.timeline) {
1018
1437
  const first = resolved.diagnostics.find((diagnostic) => diagnostic.severity === "error");
1019
1438
  throw new FrameyardError(first?.code ?? "TIMELINE_INVALID", first?.message ?? "Timeline resolution failed.", {
@@ -1025,7 +1444,9 @@ export async function renderTimeline(options) {
1025
1444
  const scratchBase = options.scratchDir ?? tmpdir();
1026
1445
  await mkdir(scratchBase, { recursive: true });
1027
1446
  const scratchRoot = await mkdtemp(path.join(scratchBase, "frameyard-timeline-"));
1447
+ const serverTiming = startTiming("timeline.server", { project: options.project.root });
1028
1448
  const server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
1449
+ serverTiming.finish({ url: server.url });
1029
1450
  const infoCache = new Map();
1030
1451
  const staticCache = new Map();
1031
1452
  const diagnostics = [...resolved.diagnostics];
@@ -1042,6 +1463,7 @@ export async function renderTimeline(options) {
1042
1463
  });
1043
1464
  let nextSegment = 0;
1044
1465
  const audio = buildAudioClips(timeline);
1466
+ const audioTiming = startTiming("timeline.audio_inspect", { clips: audio.clips.length });
1045
1467
  const audioInspection = audio.clips.length
1046
1468
  ? await inspectTimelineAudio({
1047
1469
  projectRoot: options.project.root,
@@ -1051,11 +1473,13 @@ export async function renderTimeline(options) {
1051
1473
  ...(options.signal ? { signal: options.signal } : {}),
1052
1474
  })
1053
1475
  : { clips: [], assets: [], warnings: [] };
1476
+ audioTiming.finish({ assets: audioInspection.assets.length });
1054
1477
  diagnostics.push(...audioInspection.warnings.map((message) => ({
1055
1478
  severity: "warning",
1056
1479
  code: "AUDIO_WARNING",
1057
1480
  message,
1058
1481
  })));
1482
+ const transcriptTiming = startTiming("timeline.transcript_inspect", { clips: audio.transcripts.length });
1059
1483
  const transcripts = audio.transcripts.length
1060
1484
  ? await inspectTranscriptClips({
1061
1485
  projectRoot: options.project.root,
@@ -1063,6 +1487,7 @@ export async function renderTimeline(options) {
1063
1487
  ...(options.signal ? { signal: options.signal } : {}),
1064
1488
  })
1065
1489
  : { clips: [], assets: [], warnings: [] };
1490
+ transcriptTiming.finish({ assets: transcripts.assets.length });
1066
1491
  diagnostics.push(...transcripts.warnings.map((message) => ({
1067
1492
  severity: "warning",
1068
1493
  code: "TRANSCRIPT_WARNING",
@@ -1088,6 +1513,8 @@ export async function renderTimeline(options) {
1088
1513
  scratchRoot,
1089
1514
  infoCache,
1090
1515
  staticCache,
1516
+ captureLimiter,
1517
+ sessionPool,
1091
1518
  codec,
1092
1519
  container,
1093
1520
  encoder,
@@ -1103,7 +1530,14 @@ export async function renderTimeline(options) {
1103
1530
  });
1104
1531
  }
1105
1532
  };
1533
+ const segmentsTiming = startTiming("timeline.segments", {
1534
+ segments: plan.segments.length,
1535
+ workers,
1536
+ codec,
1537
+ encoder,
1538
+ });
1106
1539
  await Promise.all(Array.from({ length: workers }, () => worker()));
1540
+ segmentsTiming.finish();
1107
1541
  const segmentPaths = segmentResults.map((result) => {
1108
1542
  if (!result) {
1109
1543
  throw new FrameyardError("INTERNAL", "A timeline segment was not rendered.");
@@ -1111,6 +1545,7 @@ export async function renderTimeline(options) {
1111
1545
  diagnostics.push(...result.diagnostics);
1112
1546
  return result.path;
1113
1547
  });
1548
+ const concatTiming = startTiming("timeline.concat", { segments: segmentPaths.length });
1114
1549
  await concatTimelineSegments({
1115
1550
  segmentPaths,
1116
1551
  listPath: path.join(scratchRoot, "segments.txt"),
@@ -1119,9 +1554,11 @@ export async function renderTimeline(options) {
1119
1554
  container,
1120
1555
  ...(options.signal ? { signal: options.signal } : {}),
1121
1556
  });
1557
+ concatTiming.finish();
1122
1558
  let finalPath = tempVideo;
1123
1559
  if (audioInspection.assets.length > 0) {
1124
1560
  const audioPath = path.join(scratchRoot, "audio.wav");
1561
+ const mixTiming = startTiming("timeline.audio_mix", { assets: audioInspection.assets.length });
1125
1562
  await mixAudioTimeline({
1126
1563
  clips: audioInspection.assets.map((asset) => ({
1127
1564
  path: asset.path,
@@ -1129,12 +1566,15 @@ export async function renderTimeline(options) {
1129
1566
  trimStart: asset.clip.trimStart,
1130
1567
  ...(asset.clip.trimDuration !== undefined ? { trimDuration: asset.clip.trimDuration } : {}),
1131
1568
  volume: asset.clip.volume,
1569
+ ...(asset.clip.envelopes !== undefined ? { volumeEnvelopes: asset.clip.envelopes } : {}),
1132
1570
  })),
1133
1571
  fps: timeline.format.fps,
1134
1572
  durationSeconds: total / timeline.format.fps,
1135
1573
  outputPath: audioPath,
1136
1574
  ...(options.signal ? { signal: options.signal } : {}),
1137
1575
  });
1576
+ mixTiming.finish();
1577
+ const muxTiming = startTiming("timeline.mux", { codec, container });
1138
1578
  await muxVideoWithAudio({
1139
1579
  videoPath: tempVideo,
1140
1580
  audioPath,
@@ -1143,13 +1583,24 @@ export async function renderTimeline(options) {
1143
1583
  container,
1144
1584
  ...(options.signal ? { signal: options.signal } : {}),
1145
1585
  });
1586
+ muxTiming.finish();
1146
1587
  finalPath = tempFinal;
1147
1588
  }
1148
1589
  if (!overwrite && (await pathExists(options.outPath))) {
1149
1590
  throw new FrameyardError("OUTPUT_EXISTS", `Output already exists: ${options.outPath}. Pass --overwrite to replace it.`);
1150
1591
  }
1592
+ const publishTiming = startTiming("timeline.publish", { out: options.outPath });
1151
1593
  await publishFile(finalPath, options.outPath, overwrite);
1594
+ publishTiming.finish();
1152
1595
  const uniqueDiagnostics = mergeDiagnostics(diagnostics);
1596
+ renderTiming.finish({
1597
+ segments: plan.segments.length,
1598
+ workers,
1599
+ codec,
1600
+ encoder,
1601
+ frames: total,
1602
+ outputBytes: (await stat(options.outPath)).size,
1603
+ });
1153
1604
  return {
1154
1605
  timeline: options.timelineId,
1155
1606
  entry: timeline.entry,
@@ -1162,6 +1613,7 @@ export async function renderTimeline(options) {
1162
1613
  framesRendered: total,
1163
1614
  segments: plan.segments.length,
1164
1615
  workers,
1616
+ transitions: timeline.transitions.length,
1165
1617
  codec,
1166
1618
  container,
1167
1619
  encoder,
@@ -1175,6 +1627,7 @@ export async function renderTimeline(options) {
1175
1627
  finally {
1176
1628
  await rm(tempVideo, { force: true });
1177
1629
  await rm(tempFinal, { force: true });
1630
+ await sessionPool.close();
1178
1631
  try {
1179
1632
  await server.close();
1180
1633
  }