frameyard 0.7.0 → 0.9.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 (334) hide show
  1. package/CHANGELOG.md +164 -7
  2. package/GLOSSARY.md +152 -18
  3. package/README.md +328 -285
  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 +1705 -106
  21. package/dist/cli.js.map +1 -1
  22. package/dist/composition/descriptor.d.ts +17 -3
  23. package/dist/composition/descriptor.d.ts.map +1 -1
  24. package/dist/composition/descriptor.js +51 -28
  25. package/dist/composition/descriptor.js.map +1 -1
  26. package/dist/composition/format.d.ts +12 -0
  27. package/dist/composition/format.d.ts.map +1 -0
  28. package/dist/composition/format.js +28 -0
  29. package/dist/composition/format.js.map +1 -0
  30. package/dist/composition/index.d.ts +2 -1
  31. package/dist/composition/index.d.ts.map +1 -1
  32. package/dist/composition/index.js +1 -0
  33. package/dist/composition/index.js.map +1 -1
  34. package/dist/context.d.ts +82 -0
  35. package/dist/context.d.ts.map +1 -0
  36. package/dist/context.js +277 -0
  37. package/dist/context.js.map +1 -0
  38. package/dist/contracts.d.ts +80 -6
  39. package/dist/contracts.d.ts.map +1 -1
  40. package/dist/contracts.js +3 -0
  41. package/dist/contracts.js.map +1 -1
  42. package/dist/doctor.d.ts +14 -1
  43. package/dist/doctor.d.ts.map +1 -1
  44. package/dist/doctor.js +166 -7
  45. package/dist/doctor.js.map +1 -1
  46. package/dist/errors.d.ts +1 -1
  47. package/dist/errors.d.ts.map +1 -1
  48. package/dist/errors.js.map +1 -1
  49. package/dist/fonts/add.d.ts +41 -0
  50. package/dist/fonts/add.d.ts.map +1 -0
  51. package/dist/fonts/add.js +259 -0
  52. package/dist/fonts/add.js.map +1 -0
  53. package/dist/fonts/binary.d.ts +4 -0
  54. package/dist/fonts/binary.d.ts.map +1 -0
  55. package/dist/fonts/binary.js +21 -0
  56. package/dist/fonts/binary.js.map +1 -0
  57. package/dist/fonts/css.d.ts +4 -0
  58. package/dist/fonts/css.d.ts.map +1 -0
  59. package/dist/fonts/css.js +50 -0
  60. package/dist/fonts/css.js.map +1 -0
  61. package/dist/fonts/fontsource.d.ts +107 -0
  62. package/dist/fonts/fontsource.d.ts.map +1 -0
  63. package/dist/fonts/fontsource.js +565 -0
  64. package/dist/fonts/fontsource.js.map +1 -0
  65. package/dist/fonts/index.d.ts +11 -0
  66. package/dist/fonts/index.d.ts.map +1 -0
  67. package/dist/fonts/index.js +11 -0
  68. package/dist/fonts/index.js.map +1 -0
  69. package/dist/fonts/install.d.ts +24 -0
  70. package/dist/fonts/install.d.ts.map +1 -0
  71. package/dist/fonts/install.js +145 -0
  72. package/dist/fonts/install.js.map +1 -0
  73. package/dist/fonts/manifest.d.ts +21 -0
  74. package/dist/fonts/manifest.d.ts.map +1 -0
  75. package/dist/fonts/manifest.js +96 -0
  76. package/dist/fonts/manifest.js.map +1 -0
  77. package/dist/fonts/options.d.ts +9 -0
  78. package/dist/fonts/options.d.ts.map +1 -0
  79. package/dist/fonts/options.js +66 -0
  80. package/dist/fonts/options.js.map +1 -0
  81. package/dist/fonts/schema.d.ts +4 -0
  82. package/dist/fonts/schema.d.ts.map +1 -0
  83. package/dist/fonts/schema.js +316 -0
  84. package/dist/fonts/schema.js.map +1 -0
  85. package/dist/fonts/summary.d.ts +16 -0
  86. package/dist/fonts/summary.d.ts.map +1 -0
  87. package/dist/fonts/summary.js +22 -0
  88. package/dist/fonts/summary.js.map +1 -0
  89. package/dist/fonts/types.d.ts +56 -0
  90. package/dist/fonts/types.d.ts.map +1 -0
  91. package/dist/fonts/types.js +8 -0
  92. package/dist/fonts/types.js.map +1 -0
  93. package/dist/fonts/verify.d.ts +33 -0
  94. package/dist/fonts/verify.d.ts.map +1 -0
  95. package/dist/fonts/verify.js +200 -0
  96. package/dist/fonts/verify.js.map +1 -0
  97. package/dist/html/document-adapter.d.ts +3 -3
  98. package/dist/html/document-adapter.d.ts.map +1 -1
  99. package/dist/html/document-adapter.js +1 -1
  100. package/dist/html/document-adapter.js.map +1 -1
  101. package/dist/html/index.d.ts +1 -1
  102. package/dist/html/index.d.ts.map +1 -1
  103. package/dist/html/parse.d.ts +10 -2
  104. package/dist/html/parse.d.ts.map +1 -1
  105. package/dist/html/parse.js +14 -5
  106. package/dist/html/parse.js.map +1 -1
  107. package/dist/html/validate.d.ts +12 -1
  108. package/dist/html/validate.d.ts.map +1 -1
  109. package/dist/html/validate.js +292 -61
  110. package/dist/html/validate.js.map +1 -1
  111. package/dist/index.d.ts +4 -4
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +2 -2
  114. package/dist/index.js.map +1 -1
  115. package/dist/inspect.d.ts +61 -0
  116. package/dist/inspect.d.ts.map +1 -0
  117. package/dist/inspect.js +325 -0
  118. package/dist/inspect.js.map +1 -0
  119. package/dist/lint.d.ts +63 -0
  120. package/dist/lint.d.ts.map +1 -0
  121. package/dist/lint.js +265 -0
  122. package/dist/lint.js.map +1 -0
  123. package/dist/policy.d.ts +69 -0
  124. package/dist/policy.d.ts.map +1 -0
  125. package/dist/policy.js +361 -0
  126. package/dist/policy.js.map +1 -0
  127. package/dist/preview/main.d.ts +2 -2
  128. package/dist/preview/main.d.ts.map +1 -1
  129. package/dist/preview/main.js +35 -14
  130. package/dist/preview/main.js.map +1 -1
  131. package/dist/preview/timeline-data.d.ts +17 -65
  132. package/dist/preview/timeline-data.d.ts.map +1 -1
  133. package/dist/preview/timeline-host.d.ts.map +1 -1
  134. package/dist/preview/timeline-host.js +73 -38
  135. package/dist/preview/timeline-host.js.map +1 -1
  136. package/dist/preview/timeline-main.d.ts.map +1 -1
  137. package/dist/preview/timeline-main.js +47 -3
  138. package/dist/preview/timeline-main.js.map +1 -1
  139. package/dist/project.d.ts +13 -8
  140. package/dist/project.d.ts.map +1 -1
  141. package/dist/project.js +20 -17
  142. package/dist/project.js.map +1 -1
  143. package/dist/renderer/browser.d.ts +2 -2
  144. package/dist/renderer/browser.d.ts.map +1 -1
  145. package/dist/renderer/browser.js +10 -4
  146. package/dist/renderer/browser.js.map +1 -1
  147. package/dist/renderer/ffmpeg.d.ts +44 -1
  148. package/dist/renderer/ffmpeg.d.ts.map +1 -1
  149. package/dist/renderer/ffmpeg.js +371 -73
  150. package/dist/renderer/ffmpeg.js.map +1 -1
  151. package/dist/renderer/index.d.ts +40 -4
  152. package/dist/renderer/index.d.ts.map +1 -1
  153. package/dist/renderer/index.js +100 -25
  154. package/dist/renderer/index.js.map +1 -1
  155. package/dist/renderer/keyframe-expressions.d.ts +39 -0
  156. package/dist/renderer/keyframe-expressions.d.ts.map +1 -0
  157. package/dist/renderer/keyframe-expressions.js +180 -0
  158. package/dist/renderer/keyframe-expressions.js.map +1 -0
  159. package/dist/renderer/timeline.d.ts +2 -0
  160. package/dist/renderer/timeline.d.ts.map +1 -1
  161. package/dist/renderer/timeline.js +168 -106
  162. package/dist/renderer/timeline.js.map +1 -1
  163. package/dist/runtime/host.d.ts.map +1 -1
  164. package/dist/runtime/host.js +159 -31
  165. package/dist/runtime/host.js.map +1 -1
  166. package/dist/scaffold.d.ts +3 -0
  167. package/dist/scaffold.d.ts.map +1 -1
  168. package/dist/scaffold.js +48 -317
  169. package/dist/scaffold.js.map +1 -1
  170. package/dist/server/composition-html.d.ts +15 -7
  171. package/dist/server/composition-html.d.ts.map +1 -1
  172. package/dist/server/composition-html.js +72 -16
  173. package/dist/server/composition-html.js.map +1 -1
  174. package/dist/server/server.d.ts.map +1 -1
  175. package/dist/server/server.js +56 -6
  176. package/dist/server/server.js.map +1 -1
  177. package/dist/server/timeline-preview-data.d.ts.map +1 -1
  178. package/dist/server/timeline-preview-data.js +12 -23
  179. package/dist/server/timeline-preview-data.js.map +1 -1
  180. package/dist/server/timeline-preview-html.d.ts.map +1 -1
  181. package/dist/server/timeline-preview-html.js +20 -3
  182. package/dist/server/timeline-preview-html.js.map +1 -1
  183. package/dist/templates.d.ts +16 -0
  184. package/dist/templates.d.ts.map +1 -0
  185. package/dist/templates.js +186 -0
  186. package/dist/templates.js.map +1 -0
  187. package/dist/timeline/easing.d.ts +36 -0
  188. package/dist/timeline/easing.d.ts.map +1 -0
  189. package/dist/timeline/easing.js +225 -0
  190. package/dist/timeline/easing.js.map +1 -0
  191. package/dist/timeline/keyframes.d.ts +23 -0
  192. package/dist/timeline/keyframes.d.ts.map +1 -0
  193. package/dist/timeline/keyframes.js +95 -0
  194. package/dist/timeline/keyframes.js.map +1 -0
  195. package/dist/timeline/plan.d.ts +6 -12
  196. package/dist/timeline/plan.d.ts.map +1 -1
  197. package/dist/timeline/plan.js +17 -30
  198. package/dist/timeline/plan.js.map +1 -1
  199. package/dist/timeline/resolve.d.ts +16 -3
  200. package/dist/timeline/resolve.d.ts.map +1 -1
  201. package/dist/timeline/resolve.js +119 -13
  202. package/dist/timeline/resolve.js.map +1 -1
  203. package/dist/timeline/scene.d.ts +126 -0
  204. package/dist/timeline/scene.d.ts.map +1 -0
  205. package/dist/timeline/scene.js +109 -0
  206. package/dist/timeline/scene.js.map +1 -0
  207. package/dist/timeline/schema.d.ts +38 -0
  208. package/dist/timeline/schema.d.ts.map +1 -1
  209. package/dist/timeline/schema.js +290 -9
  210. package/dist/timeline/schema.js.map +1 -1
  211. package/dist/timeline/transitions.d.ts +21 -0
  212. package/dist/timeline/transitions.d.ts.map +1 -0
  213. package/dist/timeline/transitions.js +23 -0
  214. package/dist/timeline/transitions.js.map +1 -0
  215. package/dist/version.d.ts +1 -1
  216. package/dist/version.js +1 -1
  217. package/docs/adr/0007-make-html-documents-the-composition-unit.md +2 -2
  218. package/docs/adr/0008-share-the-composition-document-grammar.md +1 -1
  219. package/docs/adr/0010-make-timelines-the-render-unit.md +3 -2
  220. package/docs/adr/0011-compose-timeline-frames-in-a-live-browser-host.md +1 -1
  221. package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +1 -1
  222. package/docs/adr/0014-compose-timeline-clips-with-css-blend-modes.md +38 -0
  223. package/docs/adr/0015-inherit-composition-format-from-the-project.md +28 -0
  224. package/docs/adr/0016-author-compositions-as-html-fragments.md +32 -0
  225. package/docs/adr/0017-render-timeless-compositions-as-stills.md +99 -0
  226. package/docs/adr/0018-animate-timeline-clips-with-keyframes.md +35 -0
  227. package/docs/adr/0019-make-same-track-overlap-the-transition-window.md +36 -0
  228. package/docs/adr/0020-crossfade-audio-clips-on-audio-tracks.md +27 -0
  229. package/docs/adr/0021-project-timeline-scenes-once.md +32 -0
  230. package/docs/adr/0022-gate-heavy-operations-with-a-workspace-resource-policy.md +35 -0
  231. package/docs/adr/0023-pin-fonts-as-project-assets.md +98 -0
  232. package/docs/migration-0.4.md +5 -3
  233. package/docs/migration-0.8.md +163 -0
  234. package/docs/preview-testing.md +23 -3
  235. package/docs/{v0.1-design.md → v0.x-design.md} +86 -53
  236. package/examples/blend/README.md +106 -0
  237. package/examples/blend/assets/ink.mp4 +0 -0
  238. package/examples/blend/compositions/aurora.html +55 -0
  239. package/examples/blend/compositions/backdrop.html +74 -0
  240. package/examples/blend/compositions/labels.html +149 -0
  241. package/examples/blend/fy.config.js +11 -0
  242. package/examples/blend/scripts/make-ink.sh +26 -0
  243. package/examples/blend/timelines/blend.timeline.json +155 -0
  244. package/examples/demo/README.md +2 -2
  245. package/examples/demo/compositions/demo.html +243 -251
  246. package/examples/intro/README.md +71 -0
  247. package/examples/intro/assets/fonts/figtree/LICENSE.txt +93 -0
  248. package/examples/intro/assets/fonts/figtree/figtree-latin-wght-normal.woff2 +0 -0
  249. package/examples/intro/assets/fonts/instrument-serif/LICENSE.txt +93 -0
  250. package/examples/intro/assets/fonts/instrument-serif/instrument-serif-latin-400-italic.woff2 +0 -0
  251. package/examples/intro/assets/fonts/space-mono/LICENSE.txt +93 -0
  252. package/examples/intro/assets/fonts/space-mono/space-mono-latin-400-normal.woff2 +0 -0
  253. package/examples/intro/assets/fonts/space-mono/space-mono-latin-700-normal.woff2 +0 -0
  254. package/examples/intro/assets/grain.png +0 -0
  255. package/examples/intro/assets/score.ogg +0 -0
  256. package/examples/intro/compositions/brand.html +412 -0
  257. package/examples/intro/compositions/chrome.controller.js +63 -0
  258. package/examples/intro/compositions/chrome.html +256 -0
  259. package/examples/intro/compositions/lattice.controller.js +148 -0
  260. package/examples/intro/compositions/lattice.html +308 -0
  261. package/examples/intro/compositions/opening.html +504 -0
  262. package/examples/intro/compositions/studio.html +930 -0
  263. package/examples/intro/compositions/theme.css +218 -0
  264. package/examples/intro/fy.config.js +13 -0
  265. package/examples/intro/fy.fonts.json +100 -0
  266. package/examples/intro/scripts/make-grain.mjs +79 -0
  267. package/examples/intro/scripts/make-score.mjs +702 -0
  268. package/examples/intro/timelines/intro.timeline.json +60 -0
  269. package/examples/keyframes/README.md +76 -0
  270. package/examples/keyframes/compositions/backdrop.html +60 -0
  271. package/examples/keyframes/compositions/card.html +99 -0
  272. package/examples/keyframes/fy.config.js +10 -0
  273. package/examples/keyframes/timelines/keyframes.timeline.json +60 -0
  274. package/examples/nested/README.md +112 -0
  275. package/examples/nested/assets/pulse.wav +0 -0
  276. package/examples/nested/compositions/backdrop.html +84 -0
  277. package/examples/nested/compositions/card.html +275 -0
  278. package/examples/nested/compositions/overlay.html +307 -0
  279. package/examples/nested/fy.config.js +13 -0
  280. package/examples/nested/scripts/make-pulse.sh +20 -0
  281. package/examples/nested/timelines/chapter-a.timeline.json +39 -0
  282. package/examples/nested/timelines/chapter-b.timeline.json +39 -0
  283. package/examples/nested/timelines/nested-reel.timeline.json +52 -0
  284. package/examples/static/README.md +17 -0
  285. package/examples/static/compositions/still.html +37 -0
  286. package/examples/static/fy.config.js +9 -0
  287. package/examples/static/timelines/still.timeline.json +13 -0
  288. package/examples/transitions/README.md +128 -0
  289. package/examples/transitions/assets/alpha.wav +0 -0
  290. package/examples/transitions/assets/beta.wav +0 -0
  291. package/examples/transitions/assets/gamma.wav +0 -0
  292. package/examples/transitions/compositions/alpha.html +56 -0
  293. package/examples/transitions/compositions/beta.html +56 -0
  294. package/examples/transitions/compositions/gamma.html +56 -0
  295. package/examples/transitions/fy.config.js +12 -0
  296. package/examples/transitions/timelines/audio-crossfade.timeline.json +44 -0
  297. package/examples/transitions/timelines/transitions.timeline.json +31 -0
  298. package/examples/video/README.md +4 -4
  299. package/examples/video/compositions/video-mp4.html +349 -357
  300. package/examples/video/compositions/video-overlay.html +337 -345
  301. package/examples/video/compositions/video.html +349 -357
  302. package/examples/webgl/README.md +1 -1
  303. package/examples/webgl/compositions/webgl.html +205 -213
  304. package/package.json +13 -5
  305. package/scaffolds/init/AGENTS.md +131 -0
  306. package/scaffolds/init/README.md +56 -0
  307. package/scaffolds/init/compositions/hello.html +79 -0
  308. package/scaffolds/init/fy.config.js +9 -0
  309. package/scaffolds/init/gitignore.template +1 -0
  310. package/scaffolds/init/package.json +11 -0
  311. package/scaffolds/init/props/hello.json +4 -0
  312. package/scaffolds/init/props/main.json +6 -0
  313. package/scaffolds/init/timelines/main.timeline.json +20 -0
  314. package/scaffolds/local-models/LOCAL-MODELS.md +77 -0
  315. package/scaffolds/local-models/fy.policy.json +5 -0
  316. package/scaffolds/local-models/opencode/opencode.jsonc +17 -0
  317. package/scaffolds/local-models/pi/APPEND_SYSTEM.md +21 -0
  318. package/scaffolds/local-models/pi/sandbox.json +15 -0
  319. package/scaffolds/local-models/pi/settings.json +15 -0
  320. package/skills/frameyard/README.md +1 -0
  321. package/skills/frameyard/SKILL.md +59 -59
  322. package/skills/frameyard/references/audio-assets.md +32 -3
  323. package/skills/frameyard/references/common-mistakes.md +81 -7
  324. package/skills/frameyard/references/controllers.md +5 -4
  325. package/skills/frameyard/references/deterministic-authoring.md +49 -37
  326. package/skills/frameyard/references/diagnostics.md +42 -4
  327. package/skills/frameyard/references/failure-recovery.md +56 -10
  328. package/skills/frameyard/references/fonts.md +132 -0
  329. package/skills/frameyard/references/json-results.md +165 -10
  330. package/skills/frameyard/references/quick-reference.md +222 -0
  331. package/skills/frameyard/references/timelines.md +109 -15
  332. /package/examples/demo/{video.config.js → fy.config.js} +0 -0
  333. /package/examples/video/{video.config.js → fy.config.js} +0 -0
  334. /package/examples/webgl/{video.config.js → fy.config.js} +0 -0
@@ -8,9 +8,13 @@ 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, ENCODER_SESSION_LIMIT, encodePngSequenceToFfv1, mixAudioTimeline, muxVideoWithAudio, resolveVideoEncoder, sceneLayerToCompositeLayer, } from "./ffmpeg.js";
12
12
  import { inspectTranscriptClips } from "./transcripts.js";
13
+ import { evaluatePresentation } from "../timeline/keyframes.js";
14
+ import { transitionProgress } from "../timeline/transitions.js";
15
+ import { KEYFRAME_CHANNELS } from "../timeline/schema.js";
13
16
  import { planTimelineRender } from "../timeline/plan.js";
17
+ import { projectScene, transitionSpans } from "../timeline/scene.js";
14
18
  import { resolveProjectTimeline, } from "../timeline/resolve.js";
15
19
  export const MAX_TIMELINE_WORKERS = 16;
16
20
  function validateWorkers(value) {
@@ -136,19 +140,6 @@ function compositionDiagnostic(diagnostic, timelineId, clip, compositionId) {
136
140
  function clampVolume(value) {
137
141
  return Math.min(1, Math.max(0, value));
138
142
  }
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
143
  function mapCompositionAudio(clip, audio, fps, window) {
153
144
  if (window.muted) {
154
145
  return null;
@@ -178,11 +169,13 @@ function mapCompositionAudio(clip, audio, fps, window) {
178
169
  trimStart: audio.trimStart + (effectiveStart - audio.startFrame) / fps,
179
170
  trimDuration: duration,
180
171
  volume: clampVolume(audio.volume * window.gain),
172
+ ...(window.envelopes.length > 0 ? { envelopes: window.envelopes } : {}),
181
173
  };
182
174
  }
183
175
  function collectTimelineAudio(timeline, window, clips, transcripts) {
184
176
  const fps = timeline.format.fps;
185
177
  const trackById = new Map(timeline.tracks.map((track) => [track.id, track]));
178
+ const spansByClip = transitionSpans(timeline);
186
179
  for (const clip of timeline.clips) {
187
180
  const source = clip.source;
188
181
  const mappedStart = clip.start + window.frameOffset;
@@ -194,6 +187,13 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
194
187
  }
195
188
  const clipGain = window.gain * (clip.audio?.gain ?? 1);
196
189
  const clipMuted = window.muted || (clip.audio?.muted ?? false);
190
+ const ownEnvelopes = (spansByClip.get(clip.id) ?? []).map((span) => ({
191
+ startFrame: span.start + window.frameOffset,
192
+ endFrame: span.end + window.frameOffset,
193
+ ...(span.easing !== undefined ? { easing: span.easing } : {}),
194
+ role: span.role,
195
+ }));
196
+ const envelopes = ownEnvelopes.length > 0 ? [...window.envelopes, ...ownEnvelopes] : window.envelopes;
197
197
  if (source.kind === "composition") {
198
198
  for (const transcript of source.transcriptClips) {
199
199
  transcripts.set(`${transcript.src}\u0000${transcript.words}`, transcript);
@@ -202,13 +202,13 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
202
202
  continue;
203
203
  }
204
204
  for (const audio of source.audioClips) {
205
- const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain });
205
+ const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain, envelopes });
206
206
  if (mapped) {
207
207
  clips.push(mapped);
208
208
  }
209
209
  }
210
210
  for (const audio of source.embeddedAudioClips) {
211
- const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain });
211
+ const mapped = mapCompositionAudio(clip, audio, fps, { ...window, gain: clipGain, envelopes });
212
212
  if (mapped) {
213
213
  clips.push({ ...mapped, allowSilent: true });
214
214
  }
@@ -222,6 +222,7 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
222
222
  endFrame,
223
223
  gain: clipGain,
224
224
  muted: clipMuted,
225
+ envelopes,
225
226
  }, clips, transcripts);
226
227
  continue;
227
228
  }
@@ -233,6 +234,7 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
233
234
  trimStart: clip.sourceIn / fps + (startFrame - mappedStart) / fps,
234
235
  trimDuration: (endFrame - startFrame) / fps,
235
236
  volume: clampVolume(clipGain),
237
+ ...(envelopes.length > 0 ? { envelopes } : {}),
236
238
  });
237
239
  }
238
240
  continue;
@@ -248,6 +250,7 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
248
250
  trimStart: clip.sourceIn / fps + (startFrame - mappedStart) / fps,
249
251
  trimDuration: (endFrame - startFrame) / fps,
250
252
  volume: clampVolume(clipGain * (track.gain ?? 1)),
253
+ ...(envelopes.length > 0 ? { envelopes } : {}),
251
254
  });
252
255
  }
253
256
  }
@@ -255,7 +258,7 @@ function collectTimelineAudio(timeline, window, clips, transcripts) {
255
258
  function buildAudioClips(timeline) {
256
259
  const clips = [];
257
260
  const transcripts = new Map();
258
- collectTimelineAudio(timeline, { frameOffset: 0, startFrame: 0, endFrame: timeline.durationInFrames, gain: 1, muted: false }, clips, transcripts);
261
+ collectTimelineAudio(timeline, { frameOffset: 0, startFrame: 0, endFrame: timeline.durationInFrames, gain: 1, muted: false, envelopes: [] }, clips, transcripts);
259
262
  return { clips, transcripts: [...transcripts.values()] };
260
263
  }
261
264
  /** Resolved timeline audio for preview surfaces. */
@@ -269,6 +272,58 @@ export function resolveTimelineAudio(timeline) {
269
272
  transcripts,
270
273
  };
271
274
  }
275
+ async function captureStaticCompositionLayer(options) {
276
+ const { project, timeline, clip, source, serverUrl, scratchRoot, infoCache, staticCache } = options;
277
+ const propsKey = JSON.stringify(clip.props ?? null);
278
+ const cacheKey = `${source.entry}\u0000${propsKey}`;
279
+ const cached = staticCache.get(cacheKey);
280
+ if (cached) {
281
+ return cached;
282
+ }
283
+ const pending = (async () => {
284
+ let validation = infoCache.get(cacheKey);
285
+ if (!validation) {
286
+ validation = await validateHtmlComposition({
287
+ projectRoot: project.root,
288
+ entry: source.entry,
289
+ ...(clip.props !== undefined ? { props: clip.props } : {}),
290
+ ...(project.format ? { format: project.format } : {}),
291
+ });
292
+ infoCache.set(cacheKey, validation);
293
+ }
294
+ if (!validation.info) {
295
+ throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${clip.id}".`, { diagnostics: validation.diagnostics });
296
+ }
297
+ const session = await openCompositionSession({
298
+ serverUrl,
299
+ entry: source.entry,
300
+ props: clip.props,
301
+ info: validation.info,
302
+ staticDiagnostics: validation.diagnostics,
303
+ transparentBackground: true,
304
+ });
305
+ try {
306
+ throwIfAborted(options.signal);
307
+ await session.seek(0);
308
+ const png = await session.capture();
309
+ const filePath = path.join(scratchRoot, `static-${randomUUID()}.png`);
310
+ await writeFile(filePath, png);
311
+ const diagnostics = (await session.readDiagnostics()).map((diagnostic) => compositionDiagnostic(diagnostic, timeline.id, clip, source.id));
312
+ return { path: filePath, diagnostics };
313
+ }
314
+ finally {
315
+ await session.close();
316
+ }
317
+ })();
318
+ staticCache.set(cacheKey, pending);
319
+ try {
320
+ return await pending;
321
+ }
322
+ catch (error) {
323
+ staticCache.delete(cacheKey);
324
+ throw error;
325
+ }
326
+ }
272
327
  async function captureCompositionLayer(options) {
273
328
  const { project, timeline, clip, source, segment, layerIndex, serverUrl, segmentDir, infoCache } = options;
274
329
  const propsKey = JSON.stringify(clip.props ?? null);
@@ -279,6 +334,7 @@ async function captureCompositionLayer(options) {
279
334
  projectRoot: project.root,
280
335
  entry: source.entry,
281
336
  ...(clip.props !== undefined ? { props: clip.props } : {}),
337
+ ...(project.format ? { format: project.format } : {}),
282
338
  });
283
339
  infoCache.set(cacheKey, validation);
284
340
  }
@@ -341,6 +397,7 @@ async function renderNestedTimelineRange(options) {
341
397
  serverUrl: options.serverUrl,
342
398
  scratchRoot: options.scratchRoot,
343
399
  infoCache: options.infoCache,
400
+ staticCache: options.staticCache,
344
401
  codec: "ffv1",
345
402
  background: options.timeline.background ?? "transparent",
346
403
  prefix,
@@ -377,26 +434,23 @@ async function processTimelineSegment(options) {
377
434
  throwIfAborted(options.signal);
378
435
  const clip = layer.clip;
379
436
  const source = clip.source;
380
- const transform = clip.transform ?? {
381
- fit: "contain",
382
- x: 0,
383
- y: 0,
384
- scale: 1,
385
- rotation: 0,
386
- opacity: 1,
387
- };
388
- const base = {
389
- fit: transform.fit,
390
- x: transform.x,
391
- y: transform.y,
392
- ...(transform.width !== undefined && transform.height !== undefined
393
- ? { width: transform.width, height: transform.height }
394
- : {}),
395
- scale: transform.scale,
396
- rotation: transform.rotation,
397
- opacity: transform.opacity,
398
- };
399
437
  if (source.kind === "composition") {
438
+ if (layer.capture === "still") {
439
+ const captured = await captureStaticCompositionLayer({
440
+ project,
441
+ timeline,
442
+ clip,
443
+ source,
444
+ serverUrl,
445
+ scratchRoot,
446
+ infoCache,
447
+ staticCache: options.staticCache,
448
+ ...(options.signal ? { signal: options.signal } : {}),
449
+ });
450
+ diagnostics.push(...captured.diagnostics);
451
+ layers.push(sceneLayerToCompositeLayer(layer, { path: captured.path, input: "image" }));
452
+ continue;
453
+ }
400
454
  const captured = await captureCompositionLayer({
401
455
  project,
402
456
  timeline,
@@ -410,25 +464,15 @@ async function processTimelineSegment(options) {
410
464
  ...(options.signal ? { signal: options.signal } : {}),
411
465
  });
412
466
  diagnostics.push(...captured.diagnostics);
413
- layers.push({
414
- kind: "composition",
415
- path: captured.path,
416
- sourceWidth: source.metadata.width,
417
- sourceHeight: source.metadata.height,
418
- ...base,
419
- });
467
+ layers.push(sceneLayerToCompositeLayer(layer, { path: captured.path, input: "sequence" }));
420
468
  continue;
421
469
  }
422
470
  if (source.kind === "video" || source.kind === "image") {
423
- const size = visualSourceSize(clip, timeline);
424
- layers.push({
425
- kind: source.kind,
471
+ layers.push(sceneLayerToCompositeLayer(layer, {
426
472
  path: source.absolutePath,
427
- sourceWidth: size.width,
428
- sourceHeight: size.height,
429
- ...base,
473
+ input: source.kind,
430
474
  ...(source.kind === "video" ? { sourceStartFrame: layer.sourceStart } : {}),
431
- });
475
+ }));
432
476
  continue;
433
477
  }
434
478
  if (source.kind === "timeline") {
@@ -440,17 +484,12 @@ async function processTimelineSegment(options) {
440
484
  serverUrl,
441
485
  scratchRoot,
442
486
  infoCache,
487
+ staticCache: options.staticCache,
443
488
  depth: depth + 1,
444
489
  ...(options.signal ? { signal: options.signal } : {}),
445
490
  });
446
491
  diagnostics.push(...nested.diagnostics);
447
- layers.push({
448
- kind: "timeline",
449
- path: nested.path,
450
- sourceWidth: source.timeline.format.width,
451
- sourceHeight: source.timeline.format.height,
452
- ...base,
453
- });
492
+ layers.push(sceneLayerToCompositeLayer(layer, { path: nested.path, input: "sequence" }));
454
493
  continue;
455
494
  }
456
495
  throw new FrameyardError("TIMELINE_INVALID", `Unsupported visual source for clip "${clip.id}".`);
@@ -466,6 +505,7 @@ async function processTimelineSegment(options) {
466
505
  background,
467
506
  layers,
468
507
  codec,
508
+ frameOffset: segment.start,
469
509
  ...(options.container !== undefined ? { container: options.container } : {}),
470
510
  ...(options.encoder !== undefined ? { encoder: options.encoder } : {}),
471
511
  ...(options.signal ? { signal: options.signal } : {}),
@@ -473,6 +513,51 @@ async function processTimelineSegment(options) {
473
513
  await rm(segmentDir, { recursive: true, force: true });
474
514
  return { path: segmentPath, diagnostics };
475
515
  }
516
+ function checkKeyframeEvaluation(timeline, segments) {
517
+ for (const segment of segments) {
518
+ for (const layer of segment.layers) {
519
+ const keyframes = layer.keyframes;
520
+ if (!keyframes) {
521
+ continue;
522
+ }
523
+ for (let frame = segment.start; frame < segment.end; frame += 1) {
524
+ const values = evaluatePresentation(layer.transform, keyframes, frame - layer.clip.start);
525
+ for (const channel of KEYFRAME_CHANNELS) {
526
+ if (!Number.isFinite(values[channel])) {
527
+ throw new FrameyardError("TIMELINE_CLIP_INVALID", `Clip "${layer.clip.id}" keyframes.${channel} produced a non-finite value at timeline frame ${frame}.`, {
528
+ details: {
529
+ timeline: timeline.id,
530
+ clip: layer.clip.id,
531
+ track: layer.clip.track,
532
+ channel,
533
+ frame: frame - layer.clip.start,
534
+ timelineFrame: frame,
535
+ },
536
+ });
537
+ }
538
+ }
539
+ }
540
+ }
541
+ }
542
+ }
543
+ function checkTransitionEvaluation(timeline) {
544
+ for (const transition of timeline.transitions) {
545
+ for (let frame = transition.start; frame < transition.end; frame += 1) {
546
+ const progress = transitionProgress(transition, frame);
547
+ if (!Number.isFinite(progress)) {
548
+ throw new FrameyardError("TIMELINE_TRANSITION_INVALID", `Transition "${transition.from}" → "${transition.to}" produced a non-finite progress at timeline frame ${frame}.`, {
549
+ details: {
550
+ timeline: timeline.id,
551
+ track: transition.track,
552
+ from: transition.from,
553
+ to: transition.to,
554
+ timelineFrame: frame,
555
+ },
556
+ });
557
+ }
558
+ }
559
+ }
560
+ }
476
561
  async function checkResolvedTimelineRange(options) {
477
562
  if (options.context.depth > 32) {
478
563
  throw new FrameyardError("TIMELINE_INVALID", "Nested timeline depth exceeds 32.");
@@ -480,6 +565,8 @@ async function checkResolvedTimelineRange(options) {
480
565
  const { timeline, startFrame, endFrame, context } = options;
481
566
  const plan = planTimelineRender(timeline, { startFrame, endFrame });
482
567
  const diagnostics = [];
568
+ checkKeyframeEvaluation(timeline, plan.segments);
569
+ checkTransitionEvaluation(timeline);
483
570
  const targets = new Map();
484
571
  for (const segment of plan.segments) {
485
572
  for (const layer of segment.layers) {
@@ -493,6 +580,7 @@ async function checkResolvedTimelineRange(options) {
493
580
  projectRoot: context.project.root,
494
581
  entry: source.entry,
495
582
  ...(layer.clip.props !== undefined ? { props: layer.clip.props } : {}),
583
+ ...(context.project.format ? { format: context.project.format } : {}),
496
584
  });
497
585
  if (!validation.info) {
498
586
  throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${layer.clip.id}".`, { diagnostics: validation.diagnostics });
@@ -512,6 +600,9 @@ async function checkResolvedTimelineRange(options) {
512
600
  staticDiagnostics: target.validation.diagnostics,
513
601
  });
514
602
  sessions.set(key, session);
603
+ if (target.source.metadata.static === true) {
604
+ await session.seek(0);
605
+ }
515
606
  }
516
607
  for (const segment of plan.segments) {
517
608
  for (let frame = segment.start; frame < segment.end; frame += 1) {
@@ -521,6 +612,9 @@ async function checkResolvedTimelineRange(options) {
521
612
  continue;
522
613
  }
523
614
  const source = layer.clip.source;
615
+ if (source.metadata.static === true) {
616
+ continue;
617
+ }
524
618
  const key = `${source.entry}\u0000${JSON.stringify(layer.clip.props ?? null)}`;
525
619
  const session = sessions.get(key);
526
620
  if (!session) {
@@ -579,7 +673,7 @@ export async function checkTimeline(options) {
579
673
  const startedAt = performance.now();
580
674
  const projectTimeline = options.project.timelines.get(options.timelineId);
581
675
  if (!projectTimeline) {
582
- throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
676
+ throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in fy.config.js.`);
583
677
  }
584
678
  const resolved = await resolveProjectTimeline({
585
679
  project: options.project,
@@ -655,6 +749,7 @@ export async function checkTimeline(options) {
655
749
  framesChecked: timeline.durationInFrames,
656
750
  tracks: timeline.tracks.length,
657
751
  clips: timeline.clips.length,
752
+ transitions: timeline.transitions.length,
658
753
  audio: { clips: audioInspection.clips.length, details: audioInspection.clips },
659
754
  transcripts: {
660
755
  clips: transcripts.clips.length,
@@ -669,40 +764,18 @@ async function renderResolvedTimelineFrame(options) {
669
764
  throw new FrameyardError("TIMELINE_INVALID", "Nested timeline depth exceeds 32.");
670
765
  }
671
766
  const { timeline, frame, context } = options;
672
- const plan = planTimelineRender(timeline, { startFrame: frame, endFrame: frame + 1 });
673
- const segment = plan.segments[0];
674
- if (!segment) {
675
- throw new FrameyardError("FRAME_OUT_OF_RANGE", `Frame ${frame} does not belong to a timeline segment.`);
676
- }
767
+ const scene = projectScene(timeline, frame, frame + 1);
677
768
  const diagnostics = [];
678
769
  const layers = [];
679
- for (const [layerIndex, layer] of segment.layers.entries()) {
770
+ for (const [layerIndex, layer] of scene.layers.entries()) {
680
771
  const clip = layer.clip;
681
772
  const source = clip.source;
682
- const transform = clip.transform ?? {
683
- fit: "contain",
684
- x: 0,
685
- y: 0,
686
- scale: 1,
687
- rotation: 0,
688
- opacity: 1,
689
- };
690
- const base = {
691
- fit: transform.fit,
692
- x: transform.x,
693
- y: transform.y,
694
- ...(transform.width !== undefined && transform.height !== undefined
695
- ? { width: transform.width, height: transform.height }
696
- : {}),
697
- scale: transform.scale,
698
- rotation: transform.rotation,
699
- opacity: transform.opacity,
700
- };
701
773
  if (source.kind === "composition") {
702
774
  const validation = await validateHtmlComposition({
703
775
  projectRoot: context.project.root,
704
776
  entry: source.entry,
705
777
  ...(clip.props !== undefined ? { props: clip.props } : {}),
778
+ ...(context.project.format ? { format: context.project.format } : {}),
706
779
  });
707
780
  if (!validation.info) {
708
781
  throw new FrameyardError("TIMELINE_CLIP_INVALID", `Composition "${source.id}" could not be loaded for clip "${clip.id}".`, { diagnostics: validation.diagnostics });
@@ -727,25 +800,15 @@ async function renderResolvedTimelineFrame(options) {
727
800
  finally {
728
801
  await session.close();
729
802
  }
730
- layers.push({
731
- kind: "image",
732
- path: framePath,
733
- sourceWidth: source.metadata.width,
734
- sourceHeight: source.metadata.height,
735
- ...base,
736
- });
803
+ layers.push(sceneLayerToCompositeLayer(layer, { path: framePath, input: "image" }));
737
804
  continue;
738
805
  }
739
806
  if (source.kind === "video" || source.kind === "image") {
740
- const size = visualSourceSize(clip, timeline);
741
- layers.push({
742
- kind: source.kind,
807
+ layers.push(sceneLayerToCompositeLayer(layer, {
743
808
  path: source.absolutePath,
744
- sourceWidth: size.width,
745
- sourceHeight: size.height,
746
- ...base,
809
+ input: source.kind,
747
810
  ...(source.kind === "video" ? { sourceStartFrame: layer.sourceStart } : {}),
748
- });
811
+ }));
749
812
  continue;
750
813
  }
751
814
  if (source.kind === "timeline") {
@@ -758,13 +821,7 @@ async function renderResolvedTimelineFrame(options) {
758
821
  context: { ...context, depth: context.depth + 1 },
759
822
  });
760
823
  diagnostics.push(...nestedDiagnostics);
761
- layers.push({
762
- kind: "image",
763
- path: nestedPath,
764
- sourceWidth: source.timeline.format.width,
765
- sourceHeight: source.timeline.format.height,
766
- ...base,
767
- });
824
+ layers.push(sceneLayerToCompositeLayer(layer, { path: nestedPath, input: "image" }));
768
825
  continue;
769
826
  }
770
827
  throw new FrameyardError("TIMELINE_INVALID", `Unsupported visual source for clip "${clip.id}".`);
@@ -776,6 +833,7 @@ async function renderResolvedTimelineFrame(options) {
776
833
  fps: timeline.format.fps,
777
834
  background: options.background,
778
835
  layers,
836
+ frameOffset: frame,
779
837
  ...(context.signal ? { signal: context.signal } : {}),
780
838
  });
781
839
  return diagnostics;
@@ -808,7 +866,7 @@ export async function captureTimelineStills(options) {
808
866
  const startedAt = performance.now();
809
867
  const projectTimeline = options.project.timelines.get(options.timelineId);
810
868
  if (!projectTimeline) {
811
- throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
869
+ throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in fy.config.js.`);
812
870
  }
813
871
  const resolved = await resolveProjectTimeline({
814
872
  project: options.project,
@@ -919,7 +977,7 @@ export async function renderTimeline(options) {
919
977
  const workers = clampTimelineWorkers(requestedWorkers, encoder);
920
978
  const projectTimeline = options.project.timelines.get(options.timelineId);
921
979
  if (!projectTimeline) {
922
- throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in video.config.js.`);
980
+ throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${options.timelineId}" is not listed in fy.config.js.`);
923
981
  }
924
982
  const resolved = await resolveProjectTimeline({
925
983
  project: options.project,
@@ -940,6 +998,7 @@ export async function renderTimeline(options) {
940
998
  const scratchRoot = await mkdtemp(path.join(scratchBase, "frameyard-timeline-"));
941
999
  const server = await startPreviewServer({ projectRoot: options.project.root, port: 0 });
942
1000
  const infoCache = new Map();
1001
+ const staticCache = new Map();
943
1002
  const diagnostics = [...resolved.diagnostics];
944
1003
  if (workers < requestedWorkers) {
945
1004
  diagnostics.push({
@@ -999,6 +1058,7 @@ export async function renderTimeline(options) {
999
1058
  serverUrl: server.url,
1000
1059
  scratchRoot,
1001
1060
  infoCache,
1061
+ staticCache,
1002
1062
  codec,
1003
1063
  container,
1004
1064
  encoder,
@@ -1040,6 +1100,7 @@ export async function renderTimeline(options) {
1040
1100
  trimStart: asset.clip.trimStart,
1041
1101
  ...(asset.clip.trimDuration !== undefined ? { trimDuration: asset.clip.trimDuration } : {}),
1042
1102
  volume: asset.clip.volume,
1103
+ ...(asset.clip.envelopes !== undefined ? { volumeEnvelopes: asset.clip.envelopes } : {}),
1043
1104
  })),
1044
1105
  fps: timeline.format.fps,
1045
1106
  durationSeconds: total / timeline.format.fps,
@@ -1073,6 +1134,7 @@ export async function renderTimeline(options) {
1073
1134
  framesRendered: total,
1074
1135
  segments: plan.segments.length,
1075
1136
  workers,
1137
+ transitions: timeline.transitions.length,
1076
1138
  codec,
1077
1139
  container,
1078
1140
  encoder,