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
@@ -1,6 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { writeFile } from "node:fs/promises";
3
3
  import { FrameyardError } from "../errors.js";
4
+ import { evaluatePresentation } from "../timeline/keyframes.js";
5
+ import { transitionProgress } from "../timeline/transitions.js";
6
+ import { keyframeChannelExpression, transitionGainExpression, transitionProgressExpression, } from "./keyframe-expressions.js";
4
7
  const STDERR_LIMIT = 16 * 1024;
5
8
  function captureTail(child) {
6
9
  let tail = "";
@@ -302,8 +305,7 @@ export async function probeMedia(filePath, options = {}) {
302
305
  throw new FrameyardError("FFPROBE_FAILED", `Could not parse ffprobe output for ${filePath}.`, { cause: error });
303
306
  }
304
307
  }
305
- /** Normalizes any decodable source to the 16 kHz mono PCM WAV that whisper.cpp expects. */
306
- export async function normalizeAudioForTranscription(options) {
308
+ async function normalizeAudio(options) {
307
309
  await runCaptured("ffmpeg", [
308
310
  "-hide_banner",
309
311
  "-loglevel",
@@ -318,12 +320,20 @@ export async function normalizeAudioForTranscription(options) {
318
320
  "-ac",
319
321
  "1",
320
322
  "-ar",
321
- "16000",
323
+ String(options.sampleRate),
322
324
  "-c:a",
323
325
  "pcm_s16le",
324
326
  options.outputPath,
325
327
  ], { signal: options.signal });
326
328
  }
329
+ /** Normalizes any decodable source to the 16 kHz mono PCM WAV that whisper.cpp expects. */
330
+ export async function normalizeAudioForTranscription(options) {
331
+ await normalizeAudio({ ...options, sampleRate: 16_000 });
332
+ }
333
+ /** Normalizes generated speech to the 24 kHz mono PCM WAV that Frameyard publishes. */
334
+ export async function normalizeSpeechAudio(options) {
335
+ await normalizeAudio({ ...options, sampleRate: 24_000 });
336
+ }
327
337
  export async function mixAudioTimeline(options) {
328
338
  if (options.clips.length === 0) {
329
339
  throw new FrameyardError("AUDIO_INVALID", "Cannot mix an empty audio timeline.");
@@ -340,10 +350,22 @@ export async function mixAudioTimeline(options) {
340
350
  trim.push(`duration=${formatNumber(clip.trimDuration)}`);
341
351
  }
342
352
  const delayMs = Math.round((clip.startFrame * 1000) / options.fps);
353
+ const gain = clip.volumeEnvelopes?.length
354
+ ? clip.volumeEnvelopes
355
+ .map((envelope) => transitionGainExpression({
356
+ progressExpression: `((${formatNumber(clip.startFrame)}+t*${formatNumber(options.fps)}-${formatNumber(envelope.startFrame)})/${formatNumber(envelope.endFrame - envelope.startFrame)})`,
357
+ ...(envelope.easing !== undefined ? { easing: envelope.easing } : {}),
358
+ role: envelope.role,
359
+ }))
360
+ .join("*")
361
+ : undefined;
362
+ const volume = gain === undefined
363
+ ? `volume=${formatNumber(clip.volume)}`
364
+ : `volume='${formatNumber(clip.volume)}*(${gain})':eval=frame`;
343
365
  const chain = [
344
366
  `atrim=${trim.join(":")}`,
345
367
  "asetpts=PTS-STARTPTS",
346
- `volume=${formatNumber(clip.volume)}`,
368
+ volume,
347
369
  "aresample=48000",
348
370
  "aformat=sample_fmts=fltp:channel_layouts=stereo",
349
371
  "apad",
@@ -363,40 +385,14 @@ export async function mixAudioTimeline(options) {
363
385
  args.push("-filter_complex", filters.join(";"), "-map", "[audio]", "-t", formatNumber(options.durationSeconds), "-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", options.outputPath);
364
386
  await runCaptured("ffmpeg", args, { signal: options.signal });
365
387
  }
366
- export async function startVideoEncoder(options) {
388
+ /** Drives an FFmpeg `image2pipe` encoder from PNG buffers. */
389
+ async function startPipeEncoder(options) {
367
390
  if (options.signal?.aborted) {
368
391
  throw abortError();
369
392
  }
370
- const args = [
371
- "-hide_banner",
372
- "-loglevel",
373
- "error",
374
- "-nostdin",
375
- "-y",
376
- "-f",
377
- "image2pipe",
378
- "-framerate",
379
- formatNumber(options.fps),
380
- "-i",
381
- "pipe:0",
382
- ];
383
- if (options.audioPath) {
384
- args.push("-i", options.audioPath);
385
- }
386
- args.push("-map", "0:v:0");
387
- if (options.audioPath) {
388
- args.push("-map", "1:a:0");
389
- }
390
- const codec = options.codec ?? "h264";
391
- const container = options.container ?? "mp4";
392
- args.push(...videoEncoderArgs(options.encoder ?? "libx264"), ...videoCodecTagArgs(codec, container), "-r", formatNumber(options.fps), "-frames:v", String(options.frames), "-t", formatNumber(options.durationSeconds));
393
- if (options.audioPath) {
394
- args.push(...audioEncoderArgs(codec));
395
- }
396
- args.push(...containerMuxFlags(container), ...containerMuxerArgs(container), options.outputPath);
397
393
  let child;
398
394
  try {
399
- child = spawn("ffmpeg", args, { stdio: ["pipe", "pipe", "pipe"] });
395
+ child = spawn("ffmpeg", options.args, { stdio: ["pipe", "pipe", "pipe"] });
400
396
  }
401
397
  catch (error) {
402
398
  throw new FrameyardError("FFMPEG_FAILED", "Could not start FFmpeg.", { cause: error });
@@ -492,6 +488,332 @@ export async function startVideoEncoder(options) {
492
488
  abort,
493
489
  };
494
490
  }
491
+ export async function startVideoEncoder(options) {
492
+ if (options.signal?.aborted) {
493
+ throw abortError();
494
+ }
495
+ const args = [
496
+ "-hide_banner",
497
+ "-loglevel",
498
+ "error",
499
+ "-nostdin",
500
+ "-y",
501
+ "-f",
502
+ "image2pipe",
503
+ "-framerate",
504
+ formatNumber(options.fps),
505
+ "-i",
506
+ "pipe:0",
507
+ ];
508
+ if (options.audioPath) {
509
+ args.push("-i", options.audioPath);
510
+ }
511
+ args.push("-map", "0:v:0");
512
+ if (options.audioPath) {
513
+ args.push("-map", "1:a:0");
514
+ }
515
+ const codec = options.codec ?? "h264";
516
+ const container = options.container ?? "mp4";
517
+ args.push(...videoEncoderArgs(options.encoder ?? "libx264"), ...videoCodecTagArgs(codec, container), "-r", formatNumber(options.fps), "-frames:v", String(options.frames), "-t", formatNumber(options.durationSeconds));
518
+ if (options.audioPath) {
519
+ args.push(...audioEncoderArgs(codec));
520
+ }
521
+ args.push(...containerMuxFlags(container), ...containerMuxerArgs(container), options.outputPath);
522
+ return startPipeEncoder({ args, frames: options.frames, ...(options.signal ? { signal: options.signal } : {}) });
523
+ }
524
+ function ffv1Threads(options) {
525
+ if (options.threads !== undefined) {
526
+ return options.threads;
527
+ }
528
+ const raw = process.env.FRAMEYARD_FFV1_THREADS;
529
+ if (raw === undefined) {
530
+ return 2;
531
+ }
532
+ const value = Number(raw);
533
+ return Number.isInteger(value) && value >= 0 ? value : 2;
534
+ }
535
+ /**
536
+ * Encodes PNG frames streamed over stdin into a lossless alpha-preserving FFV1
537
+ * file. The encoder starts before capture so encoding overlaps the next frame.
538
+ */
539
+ export async function startFfv1Encoder(options) {
540
+ const threads = ffv1Threads(options);
541
+ const args = [
542
+ "-hide_banner",
543
+ "-loglevel",
544
+ "error",
545
+ "-nostdin",
546
+ "-y",
547
+ "-f",
548
+ "image2pipe",
549
+ "-framerate",
550
+ formatNumber(options.fps),
551
+ "-i",
552
+ "pipe:0",
553
+ "-c:v",
554
+ "ffv1",
555
+ "-level",
556
+ "3",
557
+ "-pix_fmt",
558
+ "bgra",
559
+ ...(threads > 0 ? ["-threads", String(threads)] : []),
560
+ options.outputPath,
561
+ ];
562
+ return startPipeEncoder({ args, frames: options.frames, ...(options.signal ? { signal: options.signal } : {}) });
563
+ }
564
+ /**
565
+ * Encodes a full-frame composition capture directly into a delivery segment.
566
+ * The captured PNGs keep their alpha; a solid background is composited first so
567
+ * the H.264/HEVC/VP9 segment has no transparency.
568
+ */
569
+ export async function startDirectSegmentEncoder(options) {
570
+ if (options.background === "transparent") {
571
+ throw new FrameyardError("INTERNAL", "A direct delivery segment requires an opaque background.");
572
+ }
573
+ const encoder = options.encoder ?? encoderNameFor(options.codec, "software");
574
+ const durationSeconds = options.frames / options.fps;
575
+ const args = [
576
+ "-hide_banner",
577
+ "-loglevel",
578
+ "error",
579
+ "-nostdin",
580
+ "-y",
581
+ "-f",
582
+ "image2pipe",
583
+ "-framerate",
584
+ formatNumber(options.fps),
585
+ "-i",
586
+ "pipe:0",
587
+ "-f",
588
+ "lavfi",
589
+ "-i",
590
+ `color=c=${ffmpegBackgroundColor(options.background)}:s=${options.width}x${options.height}:r=${formatNumber(options.fps)}:d=${formatNumber(durationSeconds)}`,
591
+ "-filter_complex",
592
+ "[1:v][0:v]overlay=0:0:format=auto,format=yuv420p[out]",
593
+ "-map",
594
+ "[out]",
595
+ ...videoEncoderArgs(encoder),
596
+ ...videoCodecTagArgs(options.codec, options.container),
597
+ "-r",
598
+ formatNumber(options.fps),
599
+ "-frames:v",
600
+ String(options.frames),
601
+ "-t",
602
+ formatNumber(durationSeconds),
603
+ "-an",
604
+ ...containerMuxFlags(options.container),
605
+ ...containerMuxerArgs(options.container),
606
+ options.outputPath,
607
+ ];
608
+ return startPipeEncoder({ args, frames: options.frames, ...(options.signal ? { signal: options.signal } : {}) });
609
+ }
610
+ /**
611
+ * Encodes two full-frame composition captures directly into one delivery
612
+ * segment. Layer 0 is the base, layer 1 is the overlay, and both keep alpha.
613
+ */
614
+ export async function startDualPipeSegmentEncoder(options) {
615
+ if (options.background === "transparent") {
616
+ throw new FrameyardError("INTERNAL", "A dual-pipe delivery segment requires an opaque background.");
617
+ }
618
+ const encoder = options.encoder ?? encoderNameFor(options.codec, "software");
619
+ const durationSeconds = options.frames / options.fps;
620
+ const overlay = options.overlay;
621
+ const overlayFilters = [];
622
+ if (overlay) {
623
+ const fullFrame = overlay.width === undefined &&
624
+ overlay.height === undefined &&
625
+ overlay.sourceWidth === options.width &&
626
+ overlay.sourceHeight === options.height;
627
+ if (!fullFrame) {
628
+ overlayFilters.push(fitFilter(overlay, options.width, options.height));
629
+ }
630
+ overlayFilters.push("format=rgba");
631
+ }
632
+ const overlayGraph = overlay
633
+ ? `[1:v]${overlayFilters.join(",")}[ov];[base][ov]overlay=${placementFilter(overlay, 0)}:format=auto,format=yuv420p[out]`
634
+ : "[base][1:v]overlay=0:0:format=auto,format=yuv420p[out]";
635
+ const args = [
636
+ "-hide_banner",
637
+ "-loglevel",
638
+ "error",
639
+ "-nostdin",
640
+ "-y",
641
+ "-f",
642
+ "image2pipe",
643
+ "-framerate",
644
+ formatNumber(options.fps),
645
+ "-i",
646
+ "pipe:0",
647
+ "-f",
648
+ "image2pipe",
649
+ "-framerate",
650
+ formatNumber(options.fps),
651
+ "-i",
652
+ "pipe:3",
653
+ "-f",
654
+ "lavfi",
655
+ "-i",
656
+ `color=c=${ffmpegBackgroundColor(options.background)}:s=${options.width}x${options.height}:r=${formatNumber(options.fps)}:d=${formatNumber(durationSeconds)}`,
657
+ "-filter_complex",
658
+ `[2:v][0:v]overlay=0:0:format=auto[base];${overlayGraph}`,
659
+ "-map",
660
+ "[out]",
661
+ ...videoEncoderArgs(encoder),
662
+ ...videoCodecTagArgs(options.codec, options.container),
663
+ "-r",
664
+ formatNumber(options.fps),
665
+ "-frames:v",
666
+ String(options.frames),
667
+ "-t",
668
+ formatNumber(durationSeconds),
669
+ "-an",
670
+ ...containerMuxFlags(options.container),
671
+ ...containerMuxerArgs(options.container),
672
+ options.outputPath,
673
+ ];
674
+ let child;
675
+ try {
676
+ child = spawn("ffmpeg", args, { stdio: ["pipe", "ignore", "pipe", "pipe"] });
677
+ }
678
+ catch (error) {
679
+ throw new FrameyardError("FFMPEG_FAILED", "Could not start FFmpeg.", { cause: error });
680
+ }
681
+ const stderrTail = captureTail(child);
682
+ await new Promise((resolve, reject) => {
683
+ child.once("spawn", resolve);
684
+ child.once("error", (error) => {
685
+ if (error.code === "ENOENT") {
686
+ reject(new FrameyardError("FFMPEG_FAILED", "FFmpeg was not found on PATH. Install FFmpeg and ensure it is available.", { cause: error }));
687
+ }
688
+ else {
689
+ reject(new FrameyardError("FFMPEG_FAILED", `FFmpeg could not start: ${error.message}`, { cause: error }));
690
+ }
691
+ });
692
+ });
693
+ const input0 = child.stdio[0];
694
+ const input1 = child.stdio[3];
695
+ const closed = new Promise((resolve) => {
696
+ child.once("close", (code, signal) => resolve({ code, signal }));
697
+ });
698
+ let finished = false;
699
+ const framesWritten = [0, 0];
700
+ const onAbort = () => {
701
+ input0.destroy();
702
+ input1.destroy();
703
+ child.kill("SIGKILL");
704
+ };
705
+ options.signal?.addEventListener("abort", onAbort, { once: true });
706
+ async function abort() {
707
+ if (finished) {
708
+ return;
709
+ }
710
+ finished = true;
711
+ options.signal?.removeEventListener("abort", onAbort);
712
+ input0.destroy();
713
+ input1.destroy();
714
+ if (child.exitCode === null && child.signalCode === null) {
715
+ child.kill("SIGKILL");
716
+ }
717
+ await closed.catch(() => undefined);
718
+ }
719
+ async function write(layer, png) {
720
+ if (finished) {
721
+ throw new FrameyardError("FFMPEG_FAILED", "Cannot write a frame after the encoder finished.");
722
+ }
723
+ if (options.signal?.aborted) {
724
+ throw abortError();
725
+ }
726
+ const stream = layer === 0 ? input0 : input1;
727
+ await new Promise((resolve, reject) => {
728
+ const onError = (error) => {
729
+ stream.off("error", onError);
730
+ if (options.signal?.aborted) {
731
+ reject(abortError());
732
+ }
733
+ else {
734
+ reject(new FrameyardError("FFMPEG_FAILED", `FFmpeg stopped while accepting layer ${layer} frames: ${error.message}`, {
735
+ cause: error,
736
+ }));
737
+ }
738
+ };
739
+ stream.once("error", onError);
740
+ stream.write(png, (error) => {
741
+ stream.off("error", onError);
742
+ if (error) {
743
+ reject(new FrameyardError("FFMPEG_FAILED", `Could not write a layer ${layer} frame to FFmpeg: ${error.message}`, {
744
+ cause: error,
745
+ }));
746
+ }
747
+ else {
748
+ framesWritten[layer] += 1;
749
+ resolve();
750
+ }
751
+ });
752
+ });
753
+ }
754
+ return {
755
+ writeFrame: write,
756
+ async finish() {
757
+ if (finished) {
758
+ return;
759
+ }
760
+ finished = true;
761
+ options.signal?.removeEventListener("abort", onAbort);
762
+ input0.end();
763
+ input1.end();
764
+ const { code, signal } = await closed;
765
+ if (options.signal?.aborted) {
766
+ throw abortError();
767
+ }
768
+ if (code !== 0) {
769
+ const detail = stderrTail();
770
+ throw new FrameyardError("FFMPEG_FAILED", `FFmpeg encoding failed${signal ? ` (${signal})` : code === null ? "" : ` (exit ${code})`}${detail ? `:\n${detail}` : "."}`);
771
+ }
772
+ if (framesWritten[0] !== options.frames || framesWritten[1] !== options.frames) {
773
+ throw new FrameyardError("FFMPEG_FAILED", `FFmpeg encoded ${framesWritten[0]}/${framesWritten[1]} frames but ${options.frames} were required per layer.`);
774
+ }
775
+ },
776
+ abort,
777
+ };
778
+ }
779
+ /**
780
+ * Builds FFmpeg's narrow layer input from a projected scene layer. The mapper
781
+ * owns the scene-to-filter boundary; capture chooses the artifact and its input
782
+ * mode, while the scene owns presentation, keyframes, and transition roles.
783
+ */
784
+ export function sceneLayerToCompositeLayer(layer, artifact) {
785
+ const transform = layer.transform;
786
+ const transition = layer.transition;
787
+ return {
788
+ input: artifact.input,
789
+ path: artifact.path,
790
+ sourceWidth: layer.sourceWidth,
791
+ sourceHeight: layer.sourceHeight,
792
+ ...(transform.width !== undefined && transform.height !== undefined
793
+ ? { width: transform.width, height: transform.height }
794
+ : {}),
795
+ fit: transform.fit,
796
+ x: transform.x,
797
+ y: transform.y,
798
+ scale: transform.scale,
799
+ rotation: transform.rotation,
800
+ opacity: transform.opacity,
801
+ blend: layer.blend,
802
+ ...(artifact.sourceStartFrame !== undefined ? { sourceStartFrame: artifact.sourceStartFrame } : {}),
803
+ ...(layer.keyframes !== undefined ? { keyframes: layer.keyframes } : {}),
804
+ clipStart: layer.clip.start,
805
+ ...(transition !== undefined
806
+ ? {
807
+ transition: {
808
+ role: transition.role,
809
+ start: transition.window.start,
810
+ end: transition.window.end,
811
+ ...(transition.window.easing !== undefined ? { easing: transition.window.easing } : {}),
812
+ },
813
+ }
814
+ : {}),
815
+ };
816
+ }
495
817
  function ffmpegBackgroundColor(value) {
496
818
  if (value === "transparent") {
497
819
  return "black@0.0";
@@ -533,25 +855,165 @@ function fitFilter(layer, stageWidth, stageHeight) {
533
855
  const fittedHeight = Math.max(1, Math.round(sourceHeight * scale));
534
856
  return `scale=${fittedWidth}:${fittedHeight},pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=black@0`;
535
857
  }
536
- function layerFilter(layer, stageWidth, stageHeight, fps) {
537
- const filters = [
858
+ function layerTransform(layer) {
859
+ return {
860
+ fit: layer.fit,
861
+ x: layer.x,
862
+ y: layer.y,
863
+ ...(layer.width !== undefined && layer.height !== undefined ? { width: layer.width, height: layer.height } : {}),
864
+ scale: layer.scale,
865
+ rotation: layer.rotation,
866
+ opacity: layer.opacity,
867
+ };
868
+ }
869
+ function hasKeyframe(layer, channel) {
870
+ return layer.keyframes?.[channel] !== undefined;
871
+ }
872
+ /** The incoming layer's animated alpha at an absolute timeline frame. */
873
+ function transitionAlphaAt(layer, frame) {
874
+ if (layer.transition?.role !== "in") {
875
+ return 1;
876
+ }
877
+ return transitionProgress(layer.transition, frame);
878
+ }
879
+ /** False when a keyed or transitioning layer is invisible for every frame of the range. */
880
+ function isLayerVisibleInRange(layer, frameOffset, frames) {
881
+ const animated = hasKeyframe(layer, "opacity") || hasKeyframe(layer, "scale");
882
+ if (!animated && layer.transition?.role !== "in") {
883
+ return layer.opacity > 0 && layer.scale > 0;
884
+ }
885
+ const transform = layerTransform(layer);
886
+ const clipStart = layer.clipStart ?? 0;
887
+ for (let index = 0; index < frames; index += 1) {
888
+ const frame = frameOffset + index;
889
+ const values = evaluatePresentation(transform, layer.keyframes, frame - clipStart);
890
+ if (values.opacity * transitionAlphaAt(layer, frame) > 0 && values.scale > 0) {
891
+ return true;
892
+ }
893
+ }
894
+ return false;
895
+ }
896
+ function baseLayerFilters(layer, stageWidth, stageHeight, fps) {
897
+ const fullFrame = layer.width === undefined &&
898
+ layer.height === undefined &&
899
+ layer.sourceWidth === stageWidth &&
900
+ layer.sourceHeight === stageHeight;
901
+ return [
538
902
  // Matroska and WebM timestamps can be quantized to milliseconds. Normalize
539
903
  // every timed layer to the project frame rate before overlay framesync so
540
904
  // it does not duplicate or drop frames. Still images are already static.
541
- ...(layer.kind === "image" ? [] : [`fps=${formatNumber(fps)}`]),
542
- fitFilter(layer, stageWidth, stageHeight),
905
+ ...(layer.input === "image" ? [] : [`fps=${formatNumber(fps)}`]),
906
+ // A source that already matches the stage has no fit work to do.
907
+ ...(fullFrame ? [] : [fitFilter(layer, stageWidth, stageHeight)]),
543
908
  "format=rgba",
544
909
  ];
545
- if (layer.scale !== 1) {
546
- filters.push(`scale=iw*${formatNumber(layer.scale)}:ih*${formatNumber(layer.scale)}`);
910
+ }
911
+ /** Dimensions entering the rotate filter for this layer. */
912
+ function rotationInputSize(layer, stageWidth, stageHeight) {
913
+ const width = layer.width ?? stageWidth;
914
+ const height = layer.height ?? stageHeight;
915
+ if (layer.keyframes?.scale !== undefined) {
916
+ // Animated scale is applied after rotation, keeping the rotate input fixed.
917
+ return { width, height };
918
+ }
919
+ return {
920
+ width: Math.max(1, Math.ceil(width * layer.scale)),
921
+ height: Math.max(1, Math.ceil(height * layer.scale)),
922
+ };
923
+ }
924
+ function rotatedBounds(width, height, degrees) {
925
+ const radians = (degrees * Math.PI) / 180;
926
+ const cosine = Math.abs(Math.cos(radians));
927
+ const sine = Math.abs(Math.sin(radians));
928
+ // Two extra pixels absorb filter rounding and anti-aliased edge pixels.
929
+ return {
930
+ width: Math.max(1, Math.ceil(width * cosine + height * sine) + 2),
931
+ height: Math.max(1, Math.ceil(width * sine + height * cosine) + 2),
932
+ };
933
+ }
934
+ /**
935
+ * FFmpeg's rotate filter clips to a fixed output size. A keyed rotation uses a
936
+ * square diagonal bound so every segment uses the same size and corners stay
937
+ * visible at any angle. Static rotation uses its exact bounding box.
938
+ */
939
+ function rotationOutputSize(layer, stageWidth, stageHeight) {
940
+ const input = rotationInputSize(layer, stageWidth, stageHeight);
941
+ if (layer.keyframes?.rotation) {
942
+ const diagonal = Math.max(1, Math.ceil(Math.hypot(input.width, input.height)) + 2);
943
+ return { width: diagonal, height: diagonal };
547
944
  }
548
- if (layer.rotation !== 0) {
549
- filters.push(`rotate=angle=${formatNumber(layer.rotation)}*PI/180:fillcolor=black@0`);
945
+ return rotatedBounds(input.width, input.height, layer.rotation);
946
+ }
947
+ function needsOpacityMask(layer) {
948
+ return hasKeyframe(layer, "opacity") || hasKeyframe(layer, "scale") || layer.transition?.role === "in";
949
+ }
950
+ /**
951
+ * Presentation filters after the optional opacity mask. Animated scale is
952
+ * applied after rotation because uniform scale and rotation commute; rotation
953
+ * then keeps a fixed input size for the rotate filter.
954
+ */
955
+ function presentationLayerFilters(layer, stageWidth, stageHeight, frameOffset) {
956
+ const filters = [];
957
+ const clipStart = layer.clipStart ?? 0;
958
+ const frameOptions = { frameVariable: "n", frameOffset, clipStart };
959
+ const scaleKeys = layer.keyframes?.scale;
960
+ const rotationKeys = layer.keyframes?.rotation;
961
+ const opacityKeys = layer.keyframes?.opacity;
962
+ const addRotation = () => {
963
+ if (rotationKeys || layer.rotation !== 0) {
964
+ const output = rotationOutputSize(layer, stageWidth, stageHeight);
965
+ const outputSize = `ow=${output.width}:oh=${output.height}`;
966
+ if (rotationKeys) {
967
+ const angle = keyframeChannelExpression("rotation", rotationKeys, frameOptions);
968
+ filters.push(`rotate=angle='(${angle})*PI/180':fillcolor=black@0:${outputSize}`);
969
+ }
970
+ else {
971
+ filters.push(`rotate=angle=${formatNumber(layer.rotation)}*PI/180:fillcolor=black@0:${outputSize}`);
972
+ }
973
+ }
974
+ };
975
+ if (scaleKeys) {
976
+ addRotation();
977
+ const scale = keyframeChannelExpression("scale", scaleKeys, frameOptions);
978
+ filters.push(`scale=w='max(1,iw*(${scale}))':h='max(1,ih*(${scale}))':eval=frame`);
550
979
  }
551
- if (layer.opacity < 1) {
980
+ else {
981
+ if (layer.scale !== 1) {
982
+ filters.push(`scale=iw*${formatNumber(layer.scale)}:ih*${formatNumber(layer.scale)}`);
983
+ }
984
+ addRotation();
985
+ }
986
+ // A transitioning layer carries its opacity in the animated mask instead.
987
+ if (!opacityKeys && !scaleKeys && layer.transition?.role !== "in" && layer.opacity < 1) {
552
988
  filters.push(`colorchannelmixer=aa=${formatNumber(layer.opacity)}`);
553
989
  }
554
- return filters.join(",");
990
+ return filters;
991
+ }
992
+ /** Builds the animated alpha mask at the fitted box size. */
993
+ function opacityMaskFilter(options) {
994
+ const { layer, stageWidth, stageHeight, frameOffset, inputIndex, outputLabel } = options;
995
+ const clipStart = layer.clipStart ?? 0;
996
+ const frameOptions = { frameVariable: "N", frameOffset, clipStart };
997
+ const opacityKeys = layer.keyframes?.opacity;
998
+ const scaleKeys = layer.keyframes?.scale;
999
+ const opacity = opacityKeys
1000
+ ? keyframeChannelExpression("opacity", opacityKeys, frameOptions)
1001
+ : formatNumber(layer.opacity);
1002
+ const scale = scaleKeys ? keyframeChannelExpression("scale", scaleKeys, frameOptions) : undefined;
1003
+ const visible = scale !== undefined ? `if(gt(${scale},0),1,0)` : "1";
1004
+ const transition = layer.transition?.role === "in"
1005
+ ? transitionProgressExpression({
1006
+ frameVariable: "N",
1007
+ frameOffset,
1008
+ start: layer.transition.start,
1009
+ end: layer.transition.end,
1010
+ ...(layer.transition.easing !== undefined ? { easing: layer.transition.easing } : {}),
1011
+ })
1012
+ : undefined;
1013
+ const alpha = `min(1,max(0,(${opacity})*(${visible})${transition !== undefined ? `*(${transition})` : ""}))`;
1014
+ const width = layer.width ?? stageWidth;
1015
+ const height = layer.height ?? stageHeight;
1016
+ return `[${inputIndex}:v]format=rgba,geq=r=0:g=0:b=0:a='255*${alpha}',scale=${width}:${height}:flags=neighbor,format=rgba[${outputLabel}]`;
555
1017
  }
556
1018
  /** W3C soft-light in 0-255 space; FFmpeg's native mode uses a different formula. */
557
1019
  const SOFT_LIGHT_EXPRESSION = "if(lt(B,128), A-(1-2*B/255)*A*(1-A/255), A+(2*B/255-1)*(if(lt(A,64),((16*A/255-12)*A/255+4)*A/255,sqrt(A/255))*255-A))";
@@ -580,8 +1042,17 @@ function blendSpec(mode) {
580
1042
  sourceFirst: mode === "color-dodge" || mode === "color-burn",
581
1043
  };
582
1044
  }
583
- function placementFilter(layer) {
584
- return `x=(main_w-overlay_w)/2+${formatNumber(layer.x)}:y=(main_h-overlay_h)/2+${formatNumber(layer.y)}`;
1045
+ function placementFilter(layer, frameOffset) {
1046
+ const clipStart = layer.clipStart ?? 0;
1047
+ const frameOptions = { frameVariable: "n", frameOffset, clipStart };
1048
+ const xKeys = layer.keyframes?.x;
1049
+ const yKeys = layer.keyframes?.y;
1050
+ const x = xKeys ? keyframeChannelExpression("x", xKeys, frameOptions) : formatNumber(layer.x);
1051
+ const y = yKeys ? keyframeChannelExpression("y", yKeys, frameOptions) : formatNumber(layer.y);
1052
+ if (!xKeys && !yKeys) {
1053
+ return `x=(main_w-overlay_w)/2+${x}:y=(main_h-overlay_h)/2+${y}`;
1054
+ }
1055
+ return `x='(main_w-overlay_w)/2+(${x})':y='(main_h-overlay_h)/2+(${y})':eval=frame`;
585
1056
  }
586
1057
  /**
587
1058
  * Builds the FFmpeg filter graph for one timeline composite. Blend layers are
@@ -605,7 +1076,7 @@ function buildCompositeGraph(options) {
605
1076
  let current = "base0";
606
1077
  let inputIndex = 0;
607
1078
  let includedIndex = 0;
608
- const blendedLayers = options.layers.filter((layer) => layer.blend !== "normal" && layer.opacity > 0 && layer.scale > 0);
1079
+ const blendedLayers = options.layers.filter((layer) => layer.blend !== "normal" && isLayerVisibleInRange(layer, options.frameOffset, options.frames));
609
1080
  const canvasLabels = [];
610
1081
  if (blendedLayers.length > 0) {
611
1082
  args.push("-f", "lavfi", "-i", `color=c=black@0.0:s=${options.width}x${options.height}:r=${formatNumber(options.fps)}:d=${formatNumber(durationSeconds)}`);
@@ -620,10 +1091,19 @@ function buildCompositeGraph(options) {
620
1091
  }
621
1092
  let canvasIndex = 0;
622
1093
  for (const layer of options.layers) {
623
- if (layer.opacity <= 0 || layer.scale <= 0) {
1094
+ if (!isLayerVisibleInRange(layer, options.frameOffset, options.frames)) {
624
1095
  continue;
625
1096
  }
626
- if (layer.kind === "video") {
1097
+ const masked = needsOpacityMask(layer);
1098
+ let maskInput = 0;
1099
+ if (masked) {
1100
+ args.push("-f", "lavfi", "-i",
1101
+ // FFmpeg 6 rejects a 1x1 yuv420p source (chroma planes round to zero).
1102
+ `color=c=black@0.0:s=2x2:r=${formatNumber(options.fps)}:d=${formatNumber(durationSeconds)}`);
1103
+ inputIndex += 1;
1104
+ maskInput = inputIndex;
1105
+ }
1106
+ if (layer.input === "video") {
627
1107
  const startSeconds = (layer.sourceStartFrame ?? 0) / options.fps;
628
1108
  if (options.segment) {
629
1109
  args.push("-ss", formatNumber(startSeconds), "-t", formatNumber(durationSeconds), "-i", layer.path, "-an");
@@ -632,7 +1112,7 @@ function buildCompositeGraph(options) {
632
1112
  args.push("-ss", formatNumber(startSeconds), "-i", layer.path, "-an");
633
1113
  }
634
1114
  }
635
- else if (layer.kind === "image") {
1115
+ else if (layer.input === "image") {
636
1116
  if (options.segment) {
637
1117
  args.push("-loop", "1", "-framerate", formatNumber(options.fps), "-t", formatNumber(durationSeconds), "-i", layer.path, "-an");
638
1118
  }
@@ -645,15 +1125,32 @@ function buildCompositeGraph(options) {
645
1125
  }
646
1126
  inputIndex += 1;
647
1127
  includedIndex += 1;
648
- filters.push(`[${inputIndex}:v]${layerFilter(layer, options.width, options.height, options.fps)}[l${includedIndex}]`);
1128
+ const layerInput = inputIndex;
1129
+ const base = baseLayerFilters(layer, options.width, options.height, options.fps);
1130
+ const presentation = presentationLayerFilters(layer, options.width, options.height, options.frameOffset);
1131
+ if (masked) {
1132
+ filters.push(`[${layerInput}:v]${base.join(",")}[v${includedIndex}]`);
1133
+ filters.push(opacityMaskFilter({
1134
+ layer,
1135
+ stageWidth: options.width,
1136
+ stageHeight: options.height,
1137
+ frameOffset: options.frameOffset,
1138
+ inputIndex: maskInput,
1139
+ outputLabel: `km${includedIndex}`,
1140
+ }));
1141
+ filters.push(`[v${includedIndex}][km${includedIndex}]blend=c0_mode=normal:c1_mode=normal:c2_mode=normal:c3_mode=multiply${presentation.length > 0 ? `,${presentation.join(",")}` : ""}[l${includedIndex}]`);
1142
+ }
1143
+ else {
1144
+ filters.push(`[${layerInput}:v]${[...base, ...presentation].join(",")}[l${includedIndex}]`);
1145
+ }
649
1146
  if (layer.blend === "normal") {
650
- filters.push(`[${current}][l${includedIndex}]overlay=${placementFilter(layer)}:format=auto[b${includedIndex}]`);
1147
+ filters.push(`[${current}][l${includedIndex}]overlay=${placementFilter(layer, options.frameOffset)}:format=auto[b${includedIndex}]`);
651
1148
  current = `b${includedIndex}`;
652
1149
  continue;
653
1150
  }
654
1151
  const canvas = canvasLabels[canvasIndex];
655
1152
  canvasIndex += 1;
656
- filters.push(`[${canvas}][l${includedIndex}]overlay=${placementFilter(layer)}:format=auto,format=rgba,split=2[pb${includedIndex}][pm${includedIndex}]`);
1153
+ filters.push(`[${canvas}][l${includedIndex}]overlay=${placementFilter(layer, options.frameOffset)}:format=auto,format=rgba,split=2[pb${includedIndex}][pm${includedIndex}]`);
657
1154
  filters.push(`[${current}]format=rgba,split=3[bc${includedIndex}][bm${includedIndex}][bo${includedIndex}]`);
658
1155
  filters.push(`[bm${includedIndex}]alphaextract,format=rgba[mask${includedIndex}]`);
659
1156
  const spec = blendSpec(layer.blend);
@@ -666,24 +1163,38 @@ function buildCompositeGraph(options) {
666
1163
  }
667
1164
  return { args, filters, output: current };
668
1165
  }
669
- /** Encode a numbered PNG sequence into a lossless alpha-preserving FFV1 file. */
670
- export async function encodePngSequenceToFfv1(options) {
671
- await runCaptured("ffmpeg", [
672
- "-y",
673
- "-v",
1166
+ /** Re-encodes one full-frame video range directly into a delivery segment. */
1167
+ export async function encodeVideoSegmentDirect(options) {
1168
+ const encoder = options.encoder ?? encoderNameFor(options.codec, "software");
1169
+ const startSeconds = options.sourceStartFrame / options.fps;
1170
+ const durationSeconds = options.frames / options.fps;
1171
+ const args = [
1172
+ "-hide_banner",
1173
+ "-loglevel",
674
1174
  "error",
675
- "-framerate",
676
- formatNumber(options.fps),
1175
+ "-nostdin",
1176
+ "-y",
1177
+ ...(startSeconds > 0 ? ["-ss", formatNumber(startSeconds)] : []),
677
1178
  "-i",
678
- options.pattern,
679
- "-c:v",
680
- "ffv1",
681
- "-level",
682
- "3",
683
- "-pix_fmt",
684
- "bgra",
1179
+ options.inputPath,
1180
+ "-map",
1181
+ "0:v:0",
1182
+ "-an",
1183
+ "-vf",
1184
+ `fps=${formatNumber(options.fps)},format=rgba,format=yuv420p`,
1185
+ ...videoEncoderArgs(encoder),
1186
+ ...videoCodecTagArgs(options.codec, options.container),
1187
+ "-r",
1188
+ formatNumber(options.fps),
1189
+ "-frames:v",
1190
+ String(options.frames),
1191
+ "-t",
1192
+ formatNumber(durationSeconds),
1193
+ ...containerMuxFlags(options.container),
1194
+ ...containerMuxerArgs(options.container),
685
1195
  options.outputPath,
686
- ], { signal: options.signal });
1196
+ ];
1197
+ await runCaptured("ffmpeg", args, { signal: options.signal });
687
1198
  }
688
1199
  /** Composites one timeline segment into H.264 or an alpha-preserving FFV1 file. */
689
1200
  export async function compositeTimelineSegment(options) {
@@ -692,6 +1203,7 @@ export async function compositeTimelineSegment(options) {
692
1203
  height: options.height,
693
1204
  fps: options.fps,
694
1205
  frames: options.frames,
1206
+ frameOffset: options.frameOffset ?? 0,
695
1207
  background: options.background,
696
1208
  layers: options.layers,
697
1209
  segment: true,
@@ -766,6 +1278,7 @@ export async function compositeTimelineFrame(options) {
766
1278
  height: options.height,
767
1279
  fps: options.fps,
768
1280
  frames: 1,
1281
+ frameOffset: options.frameOffset ?? 0,
769
1282
  background: options.background,
770
1283
  layers: options.layers,
771
1284
  segment: false,