motionrender 0.0.0-stage → 0.2.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 (203) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -2
  3. package/blocks/badge.json +68 -0
  4. package/blocks/big-stat.json +240 -0
  5. package/blocks/cta-end.json +283 -0
  6. package/blocks/feature-list.json +286 -0
  7. package/blocks/footage.json +43 -0
  8. package/blocks/fx-background-swap.json +170 -0
  9. package/blocks/fx-before-after.json +212 -0
  10. package/blocks/fx-captions.json +34 -0
  11. package/blocks/fx-chat.json +368 -0
  12. package/blocks/fx-checklist.json +609 -0
  13. package/blocks/fx-clone-wall.json +181 -0
  14. package/blocks/fx-color-pop.json +92 -0
  15. package/blocks/fx-command-bridge.json +358 -0
  16. package/blocks/fx-counter-hero.json +206 -0
  17. package/blocks/fx-cta-card.json +337 -0
  18. package/blocks/fx-day-to-night.json +174 -0
  19. package/blocks/fx-duo-title.json +245 -0
  20. package/blocks/fx-duotone.json +95 -0
  21. package/blocks/fx-extruded-title.json +309 -0
  22. package/blocks/fx-gallery.json +239 -0
  23. package/blocks/fx-glass-cards.json +921 -0
  24. package/blocks/fx-glitch-title.json +373 -0
  25. package/blocks/fx-grade-look.json +91 -0
  26. package/blocks/fx-highlighter.json +53 -0
  27. package/blocks/fx-icon-orbit.json +2694 -0
  28. package/blocks/fx-lens-flare.json +325 -0
  29. package/blocks/fx-letterbox.json +105 -0
  30. package/blocks/fx-light-leak.json +140 -0
  31. package/blocks/fx-map-journey.json +443 -0
  32. package/blocks/fx-neon-outline.json +743 -0
  33. package/blocks/fx-notifications.json +456 -0
  34. package/blocks/fx-progress-ring.json +143 -0
  35. package/blocks/fx-pull-out.json +68 -0
  36. package/blocks/fx-punch-in.json +81 -0
  37. package/blocks/fx-rgb-flash.json +166 -0
  38. package/blocks/fx-shake-hit.json +120 -0
  39. package/blocks/fx-split-reveal.json +110 -0
  40. package/blocks/fx-spotlight.json +1383 -0
  41. package/blocks/fx-stats-split.json +500 -0
  42. package/blocks/fx-step-tracker.json +528 -0
  43. package/blocks/fx-sticker-freeze.json +584 -0
  44. package/blocks/fx-strike-rewrite.json +79 -0
  45. package/blocks/fx-ticker.json +179 -0
  46. package/blocks/fx-timeline.json +1287 -0
  47. package/blocks/fx-tracked-label.json +167 -0
  48. package/blocks/fx-typewriter-quote.json +164 -0
  49. package/blocks/handle.json +43 -0
  50. package/blocks/icon-row.json +404 -0
  51. package/blocks/image-caption.json +248 -0
  52. package/blocks/lower-third.json +106 -0
  53. package/blocks/phone-mockup.json +459 -0
  54. package/blocks/progress-bar.json +51 -0
  55. package/blocks/quote.json +347 -0
  56. package/blocks/section-title.json +240 -0
  57. package/blocks/split-screen.json +283 -0
  58. package/blocks/stat-grid.json +315 -0
  59. package/blocks/steps.json +266 -0
  60. package/blocks/title-hero.json +287 -0
  61. package/dist/builder.d.ts +1 -0
  62. package/dist/builder.js +169 -0
  63. package/dist/cli.js +9301 -0
  64. package/dist/fonts/LICENSE-Inter.txt +93 -0
  65. package/dist/fonts/inter-cyrillic-ext-opsz-italic.woff2 +0 -0
  66. package/dist/fonts/inter-cyrillic-ext-opsz-normal.woff2 +0 -0
  67. package/dist/fonts/inter-cyrillic-opsz-italic.woff2 +0 -0
  68. package/dist/fonts/inter-cyrillic-opsz-normal.woff2 +0 -0
  69. package/dist/fonts/inter-greek-ext-opsz-italic.woff2 +0 -0
  70. package/dist/fonts/inter-greek-ext-opsz-normal.woff2 +0 -0
  71. package/dist/fonts/inter-greek-opsz-italic.woff2 +0 -0
  72. package/dist/fonts/inter-greek-opsz-normal.woff2 +0 -0
  73. package/dist/fonts/inter-latin-ext-opsz-italic.woff2 +0 -0
  74. package/dist/fonts/inter-latin-ext-opsz-normal.woff2 +0 -0
  75. package/dist/fonts/inter-latin-opsz-italic.woff2 +0 -0
  76. package/dist/fonts/inter-latin-opsz-normal.woff2 +0 -0
  77. package/dist/fonts/inter-vietnamese-opsz-italic.woff2 +0 -0
  78. package/dist/fonts/inter-vietnamese-opsz-normal.woff2 +0 -0
  79. package/dist/index.d.ts +2 -0
  80. package/dist/index.js +2330 -0
  81. package/dist/player.d.ts +1 -0
  82. package/dist/player.js +80 -0
  83. package/dist/react.d.ts +1 -0
  84. package/dist/react.js +140 -0
  85. package/dist/runtime/canvas-runtime.js +261 -0
  86. package/dist/runtime/lottie_light_canvas.min.js +1 -0
  87. package/dist/runtime/text-layout.js +50 -0
  88. package/dist/sfx/click.wav +0 -0
  89. package/dist/sfx/ding.wav +0 -0
  90. package/dist/sfx/pop.wav +0 -0
  91. package/dist/sfx/riser.wav +0 -0
  92. package/dist/sfx/thump.wav +0 -0
  93. package/dist/sfx/tick.wav +0 -0
  94. package/dist/sfx/whoosh.wav +0 -0
  95. package/dist/types/packages/ai/src/assets.d.ts +26 -0
  96. package/dist/types/packages/ai/src/batch.d.ts +19 -0
  97. package/dist/types/packages/ai/src/compose.d.ts +34 -0
  98. package/dist/types/packages/ai/src/history.d.ts +37 -0
  99. package/dist/types/packages/ai/src/image.d.ts +19 -0
  100. package/dist/types/packages/ai/src/index.d.ts +13 -0
  101. package/dist/types/packages/ai/src/json.d.ts +2 -0
  102. package/dist/types/packages/ai/src/narrate.d.ts +63 -0
  103. package/dist/types/packages/ai/src/patch.d.ts +15 -0
  104. package/dist/types/packages/ai/src/project.d.ts +27 -0
  105. package/dist/types/packages/ai/src/prompt.d.ts +30 -0
  106. package/dist/types/packages/ai/src/providers.d.ts +109 -0
  107. package/dist/types/packages/ai/src/template.d.ts +80 -0
  108. package/dist/types/packages/ai/src/voiceover.d.ts +49 -0
  109. package/dist/types/packages/animation/src/index.d.ts +27 -0
  110. package/dist/types/packages/assets/src/cache.d.ts +26 -0
  111. package/dist/types/packages/assets/src/index.d.ts +8 -0
  112. package/dist/types/packages/builder/src/index.d.ts +270 -0
  113. package/dist/types/packages/cli/src/ai-studio-page.d.ts +1 -0
  114. package/dist/types/packages/cli/src/ai-studio.d.ts +35 -0
  115. package/dist/types/packages/cli/src/ai.d.ts +3 -0
  116. package/dist/types/packages/cli/src/args.d.ts +9 -0
  117. package/dist/types/packages/cli/src/blocks-cli.d.ts +3 -0
  118. package/dist/types/packages/cli/src/blocks.d.ts +50 -0
  119. package/dist/types/packages/cli/src/build.d.ts +1 -0
  120. package/dist/types/packages/cli/src/dev-page.d.ts +1 -0
  121. package/dist/types/packages/cli/src/dev.d.ts +13 -0
  122. package/dist/types/packages/cli/src/index.d.ts +2 -0
  123. package/dist/types/packages/cli/src/init.d.ts +10 -0
  124. package/dist/types/packages/cli/src/serve.d.ts +2 -0
  125. package/dist/types/packages/cli/src/studio.d.ts +36 -0
  126. package/dist/types/packages/cli/src/templates.d.ts +63 -0
  127. package/dist/types/packages/cli/src/thumbnail.d.ts +20 -0
  128. package/dist/types/packages/cli/src/track.d.ts +35 -0
  129. package/dist/types/packages/cli/src/worker.d.ts +13 -0
  130. package/dist/types/packages/contracts/src/index.d.ts +377 -0
  131. package/dist/types/packages/core/src/audio.d.ts +14 -0
  132. package/dist/types/packages/core/src/distributed.d.ts +50 -0
  133. package/dist/types/packages/core/src/index.d.ts +63 -0
  134. package/dist/types/packages/core/src/media.d.ts +11 -0
  135. package/dist/types/packages/core/src/review.d.ts +29 -0
  136. package/dist/types/packages/ffmpeg/src/index.d.ts +68 -0
  137. package/dist/types/packages/graphics/src/fonts.d.ts +3 -0
  138. package/dist/types/packages/graphics/src/index.d.ts +121 -0
  139. package/dist/types/packages/graphics/src/runtime.d.ts +10 -0
  140. package/dist/types/packages/jobs/src/env.d.ts +78 -0
  141. package/dist/types/packages/jobs/src/handlers.d.ts +17 -0
  142. package/dist/types/packages/jobs/src/index.d.ts +5 -0
  143. package/dist/types/packages/jobs/src/input.d.ts +18 -0
  144. package/dist/types/packages/jobs/src/queue.d.ts +70 -0
  145. package/dist/types/packages/jobs/src/redis.d.ts +42 -0
  146. package/dist/types/packages/player/src/bundle.d.ts +4 -0
  147. package/dist/types/packages/player/src/canvas-runtime.d.ts +11 -0
  148. package/dist/types/packages/player/src/index.d.ts +29 -0
  149. package/dist/types/packages/react/src/index.d.ts +76 -0
  150. package/dist/types/packages/renderer/src/index.d.ts +35 -0
  151. package/dist/types/packages/renderer/src/pool.d.ts +7 -0
  152. package/dist/types/packages/renderer/src/video.d.ts +9 -0
  153. package/dist/types/packages/schema/src/captions.d.ts +24 -0
  154. package/dist/types/packages/schema/src/expression.d.ts +1 -0
  155. package/dist/types/packages/schema/src/index.d.ts +1213 -0
  156. package/dist/types/packages/schema/src/variables.d.ts +115 -0
  157. package/dist/types/packages/storage/src/index.d.ts +78 -0
  158. package/dist/types/packages/timeline/src/index.d.ts +104 -0
  159. package/package.json +78 -4
  160. package/skills/ove/SKILL.md +356 -0
  161. package/skills/ove-ai-compose/SKILL.md +36 -0
  162. package/skills/ove-batch-videos/SKILL.md +23 -0
  163. package/skills/ove-code-video/SKILL.md +68 -0
  164. package/skills/ove-effects/SKILL.md +65 -0
  165. package/skills/ove-make-reel/SKILL.md +30 -0
  166. package/skills/ove-voiceover-captions/SKILL.md +31 -0
  167. package/studio/assets/index-CzOvWv9w.css +1 -0
  168. package/studio/assets/index-J_fvKgng.js +11 -0
  169. package/studio/index.html +16 -0
  170. package/templates/case-study/composition.json +358 -0
  171. package/templates/case-study/template.json +8 -0
  172. package/templates/event-invite/composition.json +245 -0
  173. package/templates/event-invite/template.json +8 -0
  174. package/templates/explainer-square/composition.json +1335 -0
  175. package/templates/explainer-square/template.json +13 -0
  176. package/templates/keynote-wide/composition.json +1458 -0
  177. package/templates/keynote-wide/template.json +13 -0
  178. package/templates/launch-wide/composition.json +1306 -0
  179. package/templates/launch-wide/template.json +13 -0
  180. package/templates/product-card/composition.json +196 -0
  181. package/templates/product-card/data.example.json +5 -0
  182. package/templates/product-card/template.json +8 -0
  183. package/templates/product-wide/composition.json +1609 -0
  184. package/templates/product-wide/template.json +14 -0
  185. package/templates/quote-card/composition.json +123 -0
  186. package/templates/quote-card/template.json +8 -0
  187. package/templates/sale-promo/composition.json +290 -0
  188. package/templates/sale-promo/template.json +8 -0
  189. package/templates/showcase-tall/composition.json +1646 -0
  190. package/templates/showcase-tall/template.json +14 -0
  191. package/templates/slideshow/composition.json +167 -0
  192. package/templates/slideshow/template.json +8 -0
  193. package/templates/social-reel/composition.json +252 -0
  194. package/templates/social-reel/template.json +8 -0
  195. package/templates/starter/assets/logo.svg +4 -0
  196. package/templates/starter/composition.json +84 -0
  197. package/templates/story-tall/composition.json +1379 -0
  198. package/templates/story-tall/template.json +13 -0
  199. package/themes/espresso.json +15 -0
  200. package/themes/midnight.json +15 -0
  201. package/themes/neon.json +15 -0
  202. package/themes/paper.json +15 -0
  203. package/themes/signal.json +15 -0
package/dist/index.js ADDED
@@ -0,0 +1,2330 @@
1
+ // packages/core/src/index.ts
2
+ import { mkdir as mkdir5, readFile as readFile5, writeFile as writeFile5 } from "node:fs/promises";
3
+ import { dirname as dirname3, extname as extname3, join as join9, resolve as resolve3 } from "node:path";
4
+
5
+ // packages/schema/src/index.ts
6
+ import { z as z3 } from "zod";
7
+
8
+ // packages/schema/src/variables.ts
9
+ import { z } from "zod";
10
+
11
+ // packages/schema/src/expression.ts
12
+ var operators = ["===", "!==", "==", "!=", "<=", ">=", "&&", "||", "<", ">", "+", "-", "*", "/", "%", "!", "?", ":", "(", ")", "[", "]", ".", ","];
13
+ function tokenize(source) {
14
+ const tokens = [];
15
+ let i = 0;
16
+ while (i < source.length) {
17
+ const ch = source[i];
18
+ if (/\s/.test(ch)) {
19
+ i++;
20
+ continue;
21
+ }
22
+ const num3 = /^(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?/.exec(source.slice(i));
23
+ if (num3 && !(ch === "." && /[A-Za-z_\])]/.test(source[i - 1] ?? ""))) {
24
+ tokens.push({ kind: "num", value: num3[0] });
25
+ i += num3[0].length;
26
+ continue;
27
+ }
28
+ if (ch === '"' || ch === "'") {
29
+ let value = "", j = i + 1;
30
+ while (j < source.length && source[j] !== ch) {
31
+ if (source[j] === "\\" && j + 1 < source.length) j++;
32
+ value += source[j];
33
+ j++;
34
+ }
35
+ if (j >= source.length) throw new Error("unclosed quote");
36
+ tokens.push({ kind: "str", value });
37
+ i = j + 1;
38
+ continue;
39
+ }
40
+ const name2 = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(i));
41
+ if (name2) {
42
+ tokens.push({ kind: "name", value: name2[0] });
43
+ i += name2[0].length;
44
+ continue;
45
+ }
46
+ const op = operators.find((o) => source.startsWith(o, i));
47
+ if (op) {
48
+ tokens.push({ kind: "op", value: op });
49
+ i += op.length;
50
+ continue;
51
+ }
52
+ throw new Error(`unexpected "${ch}"`);
53
+ }
54
+ return tokens;
55
+ }
56
+ function parse(source) {
57
+ const tokens = tokenize(source);
58
+ let pos = 0;
59
+ const peek = (value) => {
60
+ const t = tokens[pos];
61
+ return t && (value === void 0 || t.kind === "op" && t.value === value) ? t : void 0;
62
+ };
63
+ const expect = (value) => {
64
+ if (!peek(value)) throw new Error(`expected "${value}"${tokens[pos] ? ` before "${tokens[pos].value}"` : " at the end"}`);
65
+ pos++;
66
+ };
67
+ const binary = (next, ops) => () => {
68
+ let left = next();
69
+ while (tokens[pos]?.kind === "op" && ops.includes(tokens[pos].value)) {
70
+ const op = tokens[pos++].value;
71
+ left = { kind: "binary", op, left, right: next() };
72
+ }
73
+ return left;
74
+ };
75
+ const primary = () => {
76
+ const t = tokens[pos++];
77
+ if (!t) throw new Error("expression ended early");
78
+ if (t.kind === "num") return { kind: "lit", value: Number(t.value) };
79
+ if (t.kind === "str") return { kind: "lit", value: t.value };
80
+ if (t.kind === "name") {
81
+ if (t.value === "true" || t.value === "false") return { kind: "lit", value: t.value === "true" };
82
+ if (t.value === "null") return { kind: "lit", value: null };
83
+ if (peek("(")) {
84
+ pos++;
85
+ const args = [];
86
+ if (!peek(")")) {
87
+ args.push(ternary());
88
+ while (peek(",")) {
89
+ pos++;
90
+ args.push(ternary());
91
+ }
92
+ }
93
+ expect(")");
94
+ return { kind: "call", name: t.value, args };
95
+ }
96
+ return { kind: "name", name: t.value };
97
+ }
98
+ if (t.value === "(") {
99
+ const inner = ternary();
100
+ expect(")");
101
+ return inner;
102
+ }
103
+ throw new Error(`unexpected "${t.value}"`);
104
+ };
105
+ const postfix = () => {
106
+ let node2 = primary();
107
+ for (; ; ) {
108
+ if (peek(".")) {
109
+ pos++;
110
+ const t = tokens[pos++];
111
+ if (t?.kind !== "name") throw new Error('expected a field name after "."');
112
+ node2 = { kind: "member", target: node2, key: { kind: "lit", value: t.value } };
113
+ } else if (peek("[")) {
114
+ pos++;
115
+ const key = ternary();
116
+ expect("]");
117
+ node2 = { kind: "member", target: node2, key };
118
+ } else return node2;
119
+ }
120
+ };
121
+ const unary = () => {
122
+ if (peek("!") || peek("-")) {
123
+ const op = tokens[pos++].value;
124
+ return { kind: "unary", op, arg: unary() };
125
+ }
126
+ return postfix();
127
+ };
128
+ const mul = binary(unary, ["*", "/", "%"]), add = binary(mul, ["+", "-"]), rel = binary(add, ["<", "<=", ">", ">="]), eq = binary(rel, ["==", "!=", "===", "!=="]), and = binary(eq, ["&&"]), or = binary(and, ["||"]);
129
+ function ternary() {
130
+ const test = or();
131
+ if (!peek("?")) return test;
132
+ pos++;
133
+ const then = ternary();
134
+ expect(":");
135
+ return { kind: "cond", test, then, else: ternary() };
136
+ }
137
+ const node = ternary();
138
+ if (pos < tokens.length) throw new Error(`unexpected "${tokens[pos].value}"`);
139
+ return node;
140
+ }
141
+ var num = (v, what) => {
142
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`${what} needs a number, got ${JSON.stringify(v)}`);
143
+ return v;
144
+ };
145
+ var functions = {
146
+ round: (x, d = 0) => {
147
+ const f = 10 ** num(d, "round");
148
+ return Math.round(num(x, "round") * f) / f;
149
+ },
150
+ floor: (x) => Math.floor(num(x, "floor")),
151
+ ceil: (x) => Math.ceil(num(x, "ceil")),
152
+ abs: (x) => Math.abs(num(x, "abs")),
153
+ min: (...xs) => Math.min(...xs.map((x) => num(x, "min"))),
154
+ max: (...xs) => Math.max(...xs.map((x) => num(x, "max"))),
155
+ upper: (s) => String(s).toUpperCase(),
156
+ lower: (s) => String(s).toLowerCase(),
157
+ string: (v) => String(v),
158
+ number: (v) => num(Number(v), "number")
159
+ };
160
+ var blocked = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
161
+ function member(target, key) {
162
+ if ((Array.isArray(target) || typeof target === "string") && key === "length") return target.length;
163
+ if (Array.isArray(target)) {
164
+ if (typeof key !== "number" || !Number.isInteger(key)) throw new Error(`lists are indexed by whole numbers, got ${JSON.stringify(key)}`);
165
+ return target[key];
166
+ }
167
+ if (target && typeof target === "object" && typeof key === "string" && !blocked.has(key)) {
168
+ if (!Object.hasOwn(target, key)) throw new Error(`no field "${key}"`);
169
+ return target[key];
170
+ }
171
+ throw new Error(`cannot read "${String(key)}" of ${target === null ? "null" : typeof target}`);
172
+ }
173
+ function evaluate(node, scope) {
174
+ switch (node.kind) {
175
+ case "lit":
176
+ return node.value;
177
+ case "name":
178
+ if (!Object.hasOwn(scope, node.name)) throw new Error(`unknown variable "${node.name}"`);
179
+ return scope[node.name];
180
+ case "member":
181
+ return member(evaluate(node.target, scope), evaluate(node.key, scope));
182
+ case "call": {
183
+ const own = Object.hasOwn(scope, node.name) && typeof scope[node.name] === "function" ? scope[node.name] : void 0, fn = functions[node.name] ?? own;
184
+ if (!fn) throw new Error(`unknown function "${node.name}" (available: ${[...Object.keys(functions), ...Object.keys(scope).filter((k) => typeof scope[k] === "function")].join(", ")})`);
185
+ return fn(...node.args.map((a) => evaluate(a, scope)));
186
+ }
187
+ case "unary": {
188
+ const v = evaluate(node.arg, scope);
189
+ return node.op === "!" ? !v : -num(v, "minus");
190
+ }
191
+ case "cond":
192
+ return evaluate(node.test, scope) ? evaluate(node.then, scope) : evaluate(node.else, scope);
193
+ case "binary": {
194
+ if (node.op === "&&") return evaluate(node.left, scope) && evaluate(node.right, scope);
195
+ if (node.op === "||") return evaluate(node.left, scope) || evaluate(node.right, scope);
196
+ const a = evaluate(node.left, scope), b = evaluate(node.right, scope);
197
+ switch (node.op) {
198
+ case "==":
199
+ case "===":
200
+ return a === b;
201
+ case "!=":
202
+ case "!==":
203
+ return a !== b;
204
+ case "+":
205
+ return typeof a === "string" || typeof b === "string" ? String(a) + String(b) : num(a, "+") + num(b, "+");
206
+ case "-":
207
+ return num(a, "-") - num(b, "-");
208
+ case "*":
209
+ return num(a, "*") * num(b, "*");
210
+ case "/":
211
+ return num(a, "/") / num(b, "/");
212
+ case "%":
213
+ return num(a, "%") % num(b, "%");
214
+ case "<":
215
+ return num(a, "<") < num(b, "<");
216
+ case "<=":
217
+ return num(a, "<=") <= num(b, "<=");
218
+ case ">":
219
+ return num(a, ">") > num(b, ">");
220
+ default:
221
+ return num(a, ">=") >= num(b, ">=");
222
+ }
223
+ }
224
+ }
225
+ }
226
+ var parsed = /* @__PURE__ */ new Map();
227
+ function evaluateExpression(source, scope) {
228
+ let node = parsed.get(source);
229
+ if (!node) {
230
+ node = parse(source);
231
+ parsed.set(source, node);
232
+ }
233
+ return evaluate(node, scope);
234
+ }
235
+
236
+ // packages/schema/src/variables.ts
237
+ var colorPattern = /^(#[0-9a-fA-F]{3,8}|rgba?\(.+\)|transparent)$/;
238
+ var name = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "Names use letters, digits and _");
239
+ var scalarTypes = ["string", "number", "boolean", "color"];
240
+ var listFieldSchema = z.object({ type: z.enum(scalarTypes), default: z.union([z.string(), z.number(), z.boolean()]).optional(), description: z.string().optional() });
241
+ var variableSchema = z.object({
242
+ type: z.enum([...scalarTypes, "list"]),
243
+ default: z.union([z.string(), z.number(), z.boolean(), z.array(z.record(z.string(), z.unknown()))]).optional(),
244
+ description: z.string().optional(),
245
+ items: z.record(name, listFieldSchema).optional().describe("list only: the fields each item has, checked and given defaults like variables")
246
+ });
247
+ var variablesSchema = z.record(name, variableSchema);
248
+ var componentSchema = z.object({ description: z.string().optional(), params: variablesSchema.default({}), layers: z.array(z.unknown()) });
249
+ var componentsSchema = z.record(name, componentSchema);
250
+ var VariableError = class extends Error {
251
+ constructor(problems) {
252
+ super(`Variables:
253
+ ${problems.join("\n ")}`);
254
+ this.problems = problems;
255
+ }
256
+ problems;
257
+ };
258
+ function coerceVariable(label, declaration, value) {
259
+ const fail = (expected) => {
260
+ throw new Error(`${label}: expected ${expected}, got ${JSON.stringify(value)}`);
261
+ };
262
+ switch (declaration.type) {
263
+ case "number": {
264
+ const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
265
+ return Number.isFinite(n) ? n : fail("a number");
266
+ }
267
+ case "boolean":
268
+ return value === true || value === "true" ? true : value === false || value === "false" ? false : fail("true or false");
269
+ case "color":
270
+ return typeof value === "string" && colorPattern.test(value) ? value : fail("a colour such as #ff0000 or rgba(0,0,0,0.5)");
271
+ case "string":
272
+ return typeof value === "string" ? value : typeof value === "number" || typeof value === "boolean" ? String(value) : fail("text");
273
+ case "list": {
274
+ let list = value;
275
+ if (typeof list === "string") {
276
+ try {
277
+ list = JSON.parse(list);
278
+ } catch {
279
+ fail('a JSON list such as [{"name":"A"}]');
280
+ }
281
+ }
282
+ if (!Array.isArray(list) || !list.every((item) => item && typeof item === "object" && !Array.isArray(item))) return fail("a list of objects");
283
+ const fields = declaration.items;
284
+ if (!fields) return list;
285
+ return list.map((item, i) => {
286
+ for (const key of Object.keys(item)) if (!(key in fields)) throw new Error(`${label}[${i}].${key}: not declared in "items" (declared: ${Object.keys(fields).join(", ")})`);
287
+ return Object.fromEntries(Object.entries(fields).map(([field, decl]) => {
288
+ const v = field in item ? item[field] : decl.default;
289
+ if (v === void 0) throw new Error(`${label}[${i}].${field}: no value and no default`);
290
+ return [field, coerceVariable(`${label}[${i}].${field}`, decl, v)];
291
+ }));
292
+ });
293
+ }
294
+ }
295
+ }
296
+ function resolveVariables(declarations, supplied = {}) {
297
+ const problems = [], values = {};
298
+ for (const key of Object.keys(supplied)) if (!(key in declarations)) problems.push(`${key}: not declared in "variables"${Object.keys(declarations).length ? ` (declared: ${Object.keys(declarations).join(", ")})` : ""}`);
299
+ for (const [key, declaration] of Object.entries(declarations)) {
300
+ const value = key in supplied ? supplied[key] : declaration.default;
301
+ if (value === void 0) {
302
+ problems.push(`${key}: no value given and no default`);
303
+ continue;
304
+ }
305
+ try {
306
+ values[key] = coerceVariable(key, declaration, value);
307
+ } catch (error) {
308
+ problems.push(error.message);
309
+ }
310
+ }
311
+ if (problems.length) throw new VariableError(problems);
312
+ return values;
313
+ }
314
+ var wholeReference = /^\{\{((?:(?!\}\})[\s\S])*)\}\}$/;
315
+ var reference = /\{\{((?:(?!\}\})[\s\S])*)\}\}/g;
316
+ var expandableLists = /* @__PURE__ */ new Set(["layers", "children", "scenes", "each"]);
317
+ var truthy = (v) => Array.isArray(v) ? v.length > 0 : !!v;
318
+ var asText = (v) => v !== null && typeof v === "object" ? JSON.stringify(v) : String(v);
319
+ var plain = (v) => !!v && typeof v === "object" && !Array.isArray(v);
320
+ var omit = (o, ...keys) => Object.fromEntries(Object.entries(o).filter(([k]) => !keys.includes(k)));
321
+ function substituteVariables(raw, values, extra = {}) {
322
+ const problems = [];
323
+ const components = plain(raw) && raw.components !== void 0 ? componentsSchema.safeParse(raw.components) : void 0;
324
+ if (components && !components.success) problems.push(`components: ${components.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`);
325
+ const defs = components?.success ? components.data : {};
326
+ const evaluate2 = (source, scope, path) => {
327
+ try {
328
+ return evaluateExpression(source.trim(), scope);
329
+ } catch (error) {
330
+ problems.push(`${path}: {{${source.trim()}}}: ${error.message}`);
331
+ return void 0;
332
+ }
333
+ };
334
+ const text = (value, scope, path) => {
335
+ const whole = wholeReference.exec(value);
336
+ if (whole) {
337
+ const result2 = evaluate2(whole[1], scope, path);
338
+ return result2 === void 0 ? value : result2;
339
+ }
340
+ return value.replace(reference, (match, source) => {
341
+ const result2 = evaluate2(source, scope, path);
342
+ return result2 === void 0 ? match : asText(result2);
343
+ });
344
+ };
345
+ const visit = (node, scope, path, stack) => {
346
+ if (typeof node === "string") return text(node, scope, path);
347
+ if (Array.isArray(node)) return expandableLists.has(path.split(".").pop().replace(/\[\d+\]$/, "")) ? expandList(node, scope, path, stack) : node.map((item, i) => visit(item, scope, `${path}[${i}]`, stack));
348
+ if (plain(node)) return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, path === "" && (key === "variables" || key === "components" || key === "$schema") ? value : visit(value, scope, path ? `${path}.${key}` : key, stack)]));
349
+ return node;
350
+ };
351
+ const expandList = (items, scope, path, stack) => items.flatMap((entry, i) => {
352
+ const at = `${path}[${i}]`;
353
+ if (!plain(entry)) return [visit(entry, scope, at, stack)];
354
+ let item = entry;
355
+ if ("if" in item) {
356
+ const condition = typeof item.if === "string" ? text(item.if, scope, `${at}.if`) : item.if;
357
+ if (!truthy(condition)) return [];
358
+ item = omit(item, "if");
359
+ }
360
+ if ("repeat" in item) {
361
+ const list = typeof item.repeat === "string" ? text(item.repeat, scope, `${at}.repeat`) : item.repeat;
362
+ const as = item.as ?? "item";
363
+ if (typeof as !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(as)) {
364
+ problems.push(`${at}.as: must be a name such as "product"`);
365
+ return [];
366
+ }
367
+ if (!Array.isArray(list)) {
368
+ if (typeof list !== "string" || !reference.test(list)) problems.push(`${at}.repeat: expected a list, got ${JSON.stringify(list)}`);
369
+ reference.lastIndex = 0;
370
+ return [];
371
+ }
372
+ if (!Array.isArray(item.each)) {
373
+ problems.push(`${at}.each: put the entries to repeat in "each": [ \u2026 ]`);
374
+ return [];
375
+ }
376
+ const body = item.each;
377
+ return list.flatMap((value, index) => expandList(body, { ...scope, [as]: value, index, count: list.length }, `${at}.each`, stack));
378
+ }
379
+ if ("use" in item) {
380
+ const component = String(item.use), def = defs[component];
381
+ if (!def) {
382
+ problems.push(`${at}.use: no component "${component}"${Object.keys(defs).length ? ` (defined: ${Object.keys(defs).join(", ")})` : ""}`);
383
+ return [];
384
+ }
385
+ if (stack.includes(component)) {
386
+ problems.push(`${at}.use: component "${component}" uses itself (${[...stack, component].join(" \u2192 ")})`);
387
+ return [];
388
+ }
389
+ if ("type" in item) {
390
+ problems.push(`${at}: a placed component is always a group; remove "type"`);
391
+ return [];
392
+ }
393
+ const given = visit(item.with ?? {}, scope, `${at}.with`, stack);
394
+ let params = {};
395
+ try {
396
+ params = resolveVariables(def.params, plain(given) ? given : {});
397
+ } catch (error) {
398
+ for (const problem of error.problems ?? [error.message]) problems.push(`${at}.with.${problem}`);
399
+ return [];
400
+ }
401
+ return [{ ...visit(omit(item, "use", "with"), scope, at, stack), type: "group", children: expandList(def.layers, { ...extra, ...values, ...params }, `components.${component}.layers`, [...stack, component]) }];
402
+ }
403
+ return [visit(item, scope, at, stack)];
404
+ });
405
+ const result = visit(raw, { ...extra, ...values }, "", []);
406
+ if (problems.length) throw new VariableError(problems);
407
+ return result;
408
+ }
409
+ var interpolate = (template, values) => substituteVariables(template, values);
410
+
411
+ // packages/schema/src/captions.ts
412
+ import { z as z2 } from "zod";
413
+ var captionWordSchema = z2.object({ text: z2.string(), start: z2.number().nonnegative(), end: z2.number().nonnegative() });
414
+ var captionCueSchema = z2.object({ text: z2.string(), start: z2.number().nonnegative(), end: z2.number().nonnegative(), words: z2.array(captionWordSchema).optional() });
415
+ var clock = (value) => {
416
+ const m = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})[.,](\d{1,3})$/.exec(value.trim());
417
+ if (!m) throw new Error(`Bad caption time "${value}"`);
418
+ return Number(m[1] ?? 0) * 3600 + Number(m[2]) * 60 + Number(m[3]) + Number(m[4].padEnd(3, "0")) / 1e3;
419
+ };
420
+ function parseTimedText(text) {
421
+ const cues = [];
422
+ for (const block of text.replace(/\r/g, "").split(/\n{2,}/)) {
423
+ const lines = block.split("\n").filter((l) => l.trim());
424
+ const at = lines.findIndex((l) => l.includes("-->"));
425
+ if (at < 0) continue;
426
+ const [from, to] = lines[at].split("-->").map((s) => s.trim().split(/\s+/)[0]);
427
+ const body = lines.slice(at + 1).join(" ").replace(/<[^>]+>/g, "").replace(/\{[^}]+\}/g, "").trim();
428
+ if (body) cues.push({ text: body, start: clock(from), end: clock(to) });
429
+ }
430
+ return cues;
431
+ }
432
+ function groupCaptionWords(words, maxWords = 7) {
433
+ const cues = [];
434
+ let line = [];
435
+ const flush = () => {
436
+ if (line.length) cues.push({ text: line.map((w) => w.text).join(" "), start: line[0].start, end: line[line.length - 1].end, words: line });
437
+ line = [];
438
+ };
439
+ words.forEach((word, i) => {
440
+ line.push(word);
441
+ const next = words[i + 1];
442
+ if (!next || /[.!?]["')\]]?$/.test(word.text) || /[,;:]["')\]]?$/.test(word.text) && line.length >= 3 || next.start - word.end > 0.35 || line.length >= maxWords) flush();
443
+ });
444
+ return cues;
445
+ }
446
+ function parseCaptions(text, name2 = "", maxWords = 7) {
447
+ const trimmed = text.trim().replace(/^/, "");
448
+ if (/\.json$/i.test(name2) || trimmed.startsWith("{") || trimmed.startsWith("[")) {
449
+ const data = JSON.parse(trimmed);
450
+ const list = Array.isArray(data) ? data : data.cues ?? data.words;
451
+ if (!Array.isArray(list)) throw new Error('Caption JSON needs "words" or "cues" (or a list of them)');
452
+ const isWords = !Array.isArray(data) ? !!data.words && !data.cues : list.every((item) => typeof item?.text === "string" && !/\s/.test(item.text.trim()));
453
+ return isWords ? groupCaptionWords(z2.array(captionWordSchema).parse(list), maxWords) : z2.array(captionCueSchema).parse(list);
454
+ }
455
+ return parseTimedText(trimmed);
456
+ }
457
+ var stamp = (seconds3, separator) => {
458
+ const ms = Math.max(0, Math.round(seconds3 * 1e3));
459
+ const h = Math.floor(ms / 36e5), m = Math.floor(ms / 6e4) % 60, s = Math.floor(ms / 1e3) % 60;
460
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}${separator}${String(ms % 1e3).padStart(3, "0")}`;
461
+ };
462
+ function formatCaptions(cues, format) {
463
+ const body = cues.map((cue, i) => `${format === "srt" ? `${i + 1}
464
+ ` : ""}${stamp(cue.start, format === "srt" ? "," : ".")} --> ${stamp(cue.end, format === "srt" ? "," : ".")}
465
+ ${cue.text}`).join("\n\n");
466
+ return (format === "vtt" ? "WEBVTT\n\n" : "") + body + "\n";
467
+ }
468
+ var fillers = /* @__PURE__ */ new Set(["um", "umm", "uh", "uhh", "uhm", "er", "erm", "ah", "ahh", "hmm", "mm", "mhm"]);
469
+ var captionKey = (word) => word.toLowerCase().replace(/[^\p{L}\p{N}']+/gu, "");
470
+ function dropFillerWords(cues) {
471
+ return cues.map((cue) => {
472
+ if (cue.words?.length) {
473
+ const words = cue.words.filter((w, i, all) => {
474
+ const k = captionKey(w.text);
475
+ return k !== "" && !fillers.has(k) && !(i > 0 && captionKey(all[i - 1].text) === k && !/[.,!?;:]$/.test(all[i - 1].text));
476
+ });
477
+ return { ...cue, words, text: words.map((w) => w.text).join(" ") };
478
+ }
479
+ return { ...cue, text: cue.text.split(/\s+/).filter((w) => !fillers.has(captionKey(w))).join(" ") };
480
+ }).filter((cue) => cue.text.trim() !== "");
481
+ }
482
+ var captionLines = (cues, maxWords) => cues.flatMap((cue) => cue.words && cue.words.length > maxWords ? groupCaptionWords(cue.words, maxWords) : [cue]);
483
+
484
+ // packages/schema/src/index.ts
485
+ var color = z3.string().regex(colorPattern);
486
+ var fontWeight = z3.union([z3.number().int().min(1).max(1e3), z3.enum(["normal", "bold"])]);
487
+ var fontStyle = z3.enum(["normal", "italic"]);
488
+ var easingSchema = z3.union([z3.enum(["linear", "easeIn", "easeOut", "easeInOut", "easeInCubic", "easeOutCubic", "easeInOutCubic", "easeInQuart", "easeOutQuart", "easeInOutQuart", "easeInExpo", "easeOutExpo", "easeInOutExpo", "easeInBack", "easeOutBack", "spring", "hold", "land", "snap", "glide", "pop", "move", "exit", "drift"]).describe("land, snap, glide and pop for entries, move within the frame, exit for leaving, drift for slow continuous motion; or a standard curve"), z3.tuple([z3.number().min(0).max(1), z3.number(), z3.number().min(0).max(1), z3.number()]).describe("Cubic B\xE9zier control points [x1, y1, x2, y2], as in CSS")]);
489
+ var colorProperties = ["fill", "color", "stroke"];
490
+ var cssVariableName = /^--[a-zA-Z][\w-]*$/;
491
+ var animation = z3.object({ property: z3.union([z3.enum(["x", "y", "opacity", "rotation", "scale", "fontSize", "width", "height", "radius", "letterSpacing", "strokeWidth", "trimStart", "trimEnd", "blur", "volume", "d", ...colorProperties]), z3.string().regex(cssVariableName)]).describe("A layer property, or a CSS variable such as --progress (html layers)"), keyframes: z3.array(z3.object({ frame: z3.number().int().nonnegative(), value: z3.union([z3.number(), z3.string()]).describe("A number; a colour for fill, color and stroke; SVG path data for d (shape morphing)"), easing: easingSchema.optional().describe("Curve of the segment arriving at this keyframe") })).min(1), repeat: z3.number().int().nonnegative().optional(), reverse: z3.boolean().optional() }).superRefine((a, ctx) => {
492
+ if (a.property.startsWith("--")) {
493
+ a.keyframes.forEach((k, i) => {
494
+ if (typeof k.value === "string" && !colorPattern.test(k.value)) ctx.addIssue({ code: "custom", path: ["keyframes", i, "value"], message: `${a.property} keyframes need numbers or colours (they are interpolated)` });
495
+ });
496
+ return;
497
+ }
498
+ const wantsColor = colorProperties.includes(a.property), wantsPath = a.property === "d";
499
+ a.keyframes.forEach((k, i) => {
500
+ const ok = wantsPath ? typeof k.value === "string" && k.value.trim() !== "" : wantsColor ? typeof k.value === "string" && colorPattern.test(k.value) : typeof k.value === "number";
501
+ if (!ok) ctx.addIssue({ code: "custom", path: ["keyframes", i, "value"], message: wantsPath ? "d keyframes need SVG path data" : wantsColor ? `${a.property} keyframes need colour values` : `${a.property} keyframes need numbers` });
502
+ });
503
+ });
504
+ var origin = z3.union([z3.number(), z3.literal("center")]).default(0);
505
+ var point = z3.tuple([z3.number(), z3.number()]);
506
+ var stops = z3.array(z3.object({ offset: z3.number().min(0).max(1), color })).min(2);
507
+ var gradientSchema = z3.discriminatedUnion("type", [z3.object({ type: z3.literal("linear"), from: point, to: point, stops }), z3.object({ type: z3.literal("radial"), center: point, radius: z3.number().positive(), stops })]);
508
+ var paint = z3.union([color, gradientSchema]);
509
+ var strokeFields = { stroke: paint.optional().describe("Outline colour or gradient"), strokeWidth: z3.number().nonnegative().default(1), strokeDash: z3.array(z3.number().nonnegative()).optional().describe("Dash and gap lengths, e.g. [12, 6]"), lineCap: z3.enum(["butt", "round", "square"]).default("butt"), lineJoin: z3.enum(["miter", "round", "bevel"]).default("miter") };
510
+ var duckSchema = z3.object({ amount: z3.number().min(0).max(1).default(0.3).describe("Volume multiplier while ducked, e.g. 0.3"), attackFrames: z3.number().int().nonnegative().default(6).describe("Frames to fade down before a sound starts"), releaseFrames: z3.number().int().nonnegative().default(15).describe("Frames to come back up after it ends"), by: z3.array(z3.string()).optional().describe("Only duck under these layer ids") });
511
+ var splitPresets = ["rise", "fade", "pop", "blur", "slide", "drop", "typewriter"];
512
+ var unitProperties = ["x", "y", "opacity", "scale", "rotation", "blur", "color"];
513
+ var splitSchema = z3.object({
514
+ by: z3.enum(["words", "letters", "lines"]).default("words"),
515
+ stagger: z3.number().nonnegative().default(3).describe("Frames between one piece starting and the next"),
516
+ order: z3.enum(["forward", "reverse", "center", "edges", "random"]).default("forward").describe("center starts in the middle and spreads out; edges starts at both ends; random is shuffled the same way every render"),
517
+ preset: z3.enum(splitPresets).default("rise").describe("rise: up into place; fade; pop: grows in with a small overshoot; blur: comes into focus; slide: in from the right; drop: down into place; typewriter: appears at once (use with letters)"),
518
+ duration: z3.number().int().positive().default(18).describe("Frames each piece takes to arrive, for presets"),
519
+ animations: z3.array(animation).optional().describe("Custom animation for every piece, replacing the preset: x, y, opacity, scale, rotation, blur or color")
520
+ }).superRefine((s, ctx) => {
521
+ (s.animations ?? []).forEach((a, i) => {
522
+ if (!unitProperties.includes(a.property)) ctx.addIssue({ code: "custom", path: ["animations", i, "property"], message: `Pieces of split text can animate ${unitProperties.join(", ")}, not ${a.property}` });
523
+ });
524
+ });
525
+ var spanSchema = z3.object({ text: z3.string(), color: paint.optional(), fontSize: z3.number().positive().optional(), fontWeight: fontWeight.optional(), fontStyle: fontStyle.optional(), fontFamily: z3.string().optional(), background: color.optional().describe("Highlight behind the span"), underline: z3.boolean().optional() });
526
+ var cssFilter = z3.string().regex(/^\s*((blur|brightness|contrast|grayscale|hue-rotate|invert|opacity|saturate|sepia|drop-shadow)\([^()]*(\([^()]*\)[^()]*)*\)\s*)+$/, 'Use CSS filter functions such as "brightness(1.2) saturate(0.5)"');
527
+ var blendModes = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity"];
528
+ var sfxNames = ["click", "tick", "pop", "whoosh", "thump", "ding", "riser"];
529
+ var isBuiltinSound = (src) => src.startsWith("sfx:");
530
+ var builtinSoundName = (src) => src.slice(4);
531
+ var gradeLooks = ["warm", "cool", "cinematic", "teal-orange", "noir", "vivid", "faded"];
532
+ var gradeSchema = z3.object({
533
+ look: z3.enum(gradeLooks).optional().describe("A starting look; the other numbers are added to it"),
534
+ exposure: z3.number().min(-3).max(3).optional().describe("Brightness in stops: 1 doubles the light"),
535
+ contrast: z3.number().min(-1).max(1).optional(),
536
+ saturation: z3.number().min(-1).max(1).optional().describe("-1 is black and white"),
537
+ temperature: z3.number().min(-1).max(1).optional().describe("Negative cooler (blue), positive warmer (orange)"),
538
+ tint: z3.number().min(-1).max(1).optional().describe("Negative greener, positive more magenta"),
539
+ shadows: z3.number().min(-1).max(1).optional().describe("Lift (positive) or crush (negative) the dark tones"),
540
+ highlights: z3.number().min(-1).max(1).optional().describe("Boost (positive) or pull back (negative) the bright tones"),
541
+ fade: z3.number().min(0).max(1).optional().describe("Raise the blacks for a faded film look")
542
+ }).describe("Colour grading: exposure, contrast, saturation, white balance, shadows and highlights, or a look. Not animatable; cross-fade two graded layers to change it over time.");
543
+ var baseLayer = z3.object({
544
+ id: z3.string().min(1),
545
+ type: z3.enum(["text", "image", "video", "audio", "captions", "rect", "circle", "path", "group", "html", "lottie", "shader", "code", "react"]),
546
+ grade: gradeSchema.optional().describe("Colour grading for this layer and everything inside it."),
547
+ matte: z3.object({ src: z3.string().min(1).describe("A video or picture, as an asset id or path: made with any tool (rembg, Robust Video Matting, a video editor)"), mode: z3.enum(["luma", "alpha"]).default("luma").describe("luma: white keeps the layer, black hides it; alpha: the matte's own transparency"), invert: z3.boolean().default(false).describe("Keep what the matte hides"), type: z3.enum(["video", "image"]).optional().describe("Left out, picture file extensions mean image, anything else video"), trimStart: z3.number().int().nonnegative().optional().describe("Frames of a video matte to skip; a video layer's matte follows its own trim and speed"), playbackRate: z3.number().positive().max(16).optional().describe("Speed of a video matte; a tiny value holds one frame") }).optional().describe("Show this layer only where a matte says so (a cut-out of the speaker, a shape, a gradient). Becomes a masking group when the composition loads."),
548
+ shadow: z3.object({ color, blur: z3.number().nonnegative().default(0), offsetX: z3.number().default(0), offsetY: z3.number().default(0) }).optional().describe("Drop shadow; offsets are in canvas pixels and ignore the layer's scale and rotation"),
549
+ blur: z3.number().nonnegative().default(0).describe("Gaussian blur in pixels"),
550
+ filter: cssFilter.optional(),
551
+ blendMode: z3.enum(blendModes).default("normal").describe("How the layer mixes with what is drawn below it"),
552
+ startFrame: z3.number().int().nonnegative().default(0),
553
+ endFrame: z3.number().int().positive().optional(),
554
+ x: z3.number().default(0),
555
+ y: z3.number().default(0),
556
+ width: z3.number().positive().optional(),
557
+ height: z3.number().positive().optional(),
558
+ opacity: z3.number().min(0).max(1).default(1),
559
+ rotation: z3.number().default(0),
560
+ scale: z3.number().positive().default(1),
561
+ originX: origin,
562
+ originY: origin,
563
+ zIndex: z3.number().int().default(0),
564
+ visible: z3.boolean().default(true),
565
+ fixed: z3.boolean().default(false).describe("Stays in place when the scene camera moves: captions, logos, overlays (top-level scene layers only)"),
566
+ animations: z3.array(animation).default([])
567
+ });
568
+ var outputFormats = ["mp4", "webm", "mov", "gif", "png"];
569
+ var exportSchema = z3.object({
570
+ format: z3.enum(outputFormats).default("mp4"),
571
+ codec: z3.enum(["h264", "h265"]).default("h264").describe("mp4 only"),
572
+ transparent: z3.boolean().default(false).describe("Leave the background transparent (webm, mov, png only)"),
573
+ audio: z3.string().optional().describe("Audio asset id, or a path relative to the composition file"),
574
+ audioVolume: z3.number().min(0).max(4).default(1).describe("Volume of export.audio"),
575
+ duck: duckSchema.optional().describe("Duck export.audio under the other sounds"),
576
+ loudness: z3.number().min(-40).max(-5).optional().describe("Make the sound this loud overall, in LUFS: -14 for most streaming and social sites, -16 for podcasts and spoken explainers. Peaks are limited to about -1.5 dBFS"),
577
+ crf: z3.number().int().min(0).max(63).optional().describe("Constant quality: lower is better and larger (defaults: 20 H.264, 24 H.265, 32 VP9)"),
578
+ bitrate: z3.string().regex(/^\d+(\.\d+)?[kKmMgG]?$/, 'Use a number with k, M or G, e.g. "8M" or "256k"').optional().describe('Target video data rate such as "8M"; wins over crf'),
579
+ audioBitrate: z3.string().regex(/^\d+(\.\d+)?[kKmMgG]?$/, 'Use a number with k, M or G, e.g. "8M" or "256k"').optional().describe('Audio data rate such as "256k"'),
580
+ scale: z3.number().gt(0).max(4).default(1).describe("Render at this fraction of the size, e.g. 0.5 for fast drafts (sizes round to even numbers)")
581
+ }).superRefine((e, ctx) => {
582
+ if (e.transparent && !["webm", "mov", "png"].includes(e.format)) ctx.addIssue({ code: "custom", path: ["transparent"], message: `${e.format} cannot store transparency; use webm, mov or png` });
583
+ if (e.codec !== "h264" && e.format !== "mp4") ctx.addIssue({ code: "custom", path: ["codec"], message: "codec applies to mp4 only" });
584
+ });
585
+ var transitionSchema = z3.object({
586
+ type: z3.enum(["fade", "slide", "wipe", "zoom", "iris", "push", "dip", "blur"]).describe("iris: a circle opening (or closing) on x, y; push: comes in and pushes the scene before it out the same way (transitionIn only); dip: through a colour; blur: in or out of focus"),
587
+ durationFrames: z3.number().int().positive(),
588
+ direction: z3.enum(["left", "right", "top", "bottom"]).default("left").describe("slide/wipe/push: the side the scene enters from, or exits towards"),
589
+ color: color.default("#000000").describe("dip: the colour passed through"),
590
+ x: z3.number().optional().describe("iris: centre of the circle (default: the frame centre)"),
591
+ y: z3.number().optional(),
592
+ easing: easingSchema.default("easeInOut")
593
+ });
594
+ var layerSchema = z3.discriminatedUnion("type", [
595
+ baseLayer.extend({
596
+ type: z3.literal("text"),
597
+ content: z3.string().default("").describe("The text; leave out when using spans"),
598
+ spans: z3.array(spanSchema).optional().describe("Rich text: styled pieces laid out together, used instead of content"),
599
+ fontSize: z3.number().positive().default(48),
600
+ fontFamily: z3.string().default("Inter").describe("An installed font, the family of a font asset, or a comma-separated list"),
601
+ fontWeight: fontWeight.default(400),
602
+ fontStyle: fontStyle.default("normal"),
603
+ letterSpacing: z3.number().default(0).describe("Extra pixels between characters"),
604
+ lineHeight: z3.number().positive().default(1.2),
605
+ color: paint.default("#ffffff"),
606
+ ...strokeFields,
607
+ lineJoin: strokeFields.lineJoin.removeDefault().default("round").describe("round avoids spikes on letter corners"),
608
+ textAlign: z3.enum(["left", "center", "right"]).default("left"),
609
+ maxWidth: z3.number().positive().optional().describe("Wrap lines to this width in pixels"),
610
+ maxLines: z3.number().int().positive().optional().describe("Cut to this many lines, ending in an ellipsis, unless minFontSize lets the text shrink to fit"),
611
+ minFontSize: z3.number().positive().optional().describe("Shrink the text, down to this size, until it fits maxWidth and maxLines without splitting words"),
612
+ split: splitSchema.optional().describe("Animate the text word by word, letter by letter or line by line")
613
+ }).superRefine((l, ctx) => {
614
+ if (l.split?.by === "letters" && l.spans?.length) ctx.addIssue({ code: "custom", path: ["split", "by"], message: "Rich text (spans) splits by words or lines, not letters" });
615
+ }),
616
+ // HTML and CSS drawn as part of the frame. No scripts run and nothing loads from the web: pictures come from assets (by id or path in
617
+ // src="…" or url(…)), fonts are Inter and the composition's font assets. vars are CSS custom properties on the root element; animate
618
+ // them with keyframes on "--name". --t (seconds since the layer started) and --frame are set every frame, so CSS @keyframes can be
619
+ // placed exactly: animation: rise 0.6s ease-out both paused; animation-delay: calc(var(--t) * -1s).
620
+ baseLayer.extend({
621
+ type: z3.literal("html"),
622
+ html: z3.string().describe("Markup, as in an HTML body; scripts and event handlers are removed"),
623
+ css: z3.string().default("").describe("A style sheet for the markup"),
624
+ width: z3.number().positive().describe("The box the markup is laid out in, in pixels"),
625
+ height: z3.number().positive(),
626
+ vars: z3.record(z3.string().regex(cssVariableName, "CSS variable names start with --"), z3.union([z3.number(), z3.string()])).default({}).describe('CSS custom properties set on the root, e.g. { "--accent": "#22d3ee" }'),
627
+ resolution: z3.number().min(0.5).max(4).default(1).describe("Raster detail: 2 keeps it sharp when scaled up or under a camera zoom (slower)")
628
+ }).superRefine((l, ctx) => {
629
+ for (const [field, text] of [["html", l.html], ["css", l.css]]) if (/(src\s*=\s*["']?|url\(\s*["']?)(https?:)?\/\//i.test(text)) ctx.addIssue({ code: "custom", path: [field], message: "html layers cannot load from the web: add the picture or font as an asset and refer to it by id or path" });
630
+ }),
631
+ // A Lottie animation (After Effects via Bodymovin, or LottieFiles), seeked to the exact frame. Expressions are not supported (they
632
+ // are JavaScript); pictures must be embedded in the JSON. Timing works like a video layer.
633
+ baseLayer.extend({
634
+ type: z3.literal("lottie"),
635
+ src: z3.string().min(1).describe("A lottie asset id, or a .json file relative to the composition"),
636
+ width: z3.number().positive(),
637
+ height: z3.number().positive(),
638
+ fit: z3.enum(["contain", "cover", "fill"]).default("contain"),
639
+ trimStart: z3.number().nonnegative().default(0).describe("Frames of the animation to skip, at the composition fps"),
640
+ playbackRate: z3.number().positive().max(16).default(1),
641
+ loop: z3.boolean().default(false).describe("Start again when it ends; otherwise it holds its last frame")
642
+ }),
643
+ // A React component drawn every frame: the bundle that `ove build video.tsx` writes renders the component for this frame to markup
644
+ // (react-dom/server), which is drawn like an html layer. Like code layers it runs the author's JavaScript, so it needs --allow-code.
645
+ baseLayer.extend({
646
+ type: z3.literal("react"),
647
+ src: z3.string().min(1).describe("The bundle ove build wrote, relative to the composition"),
648
+ width: z3.number().positive(),
649
+ height: z3.number().positive(),
650
+ durationInFrames: z3.number().int().positive().describe("The length useVideoConfig() reports to the component"),
651
+ props: z3.record(z3.string(), z3.unknown()).default({}).describe("Handed to the component as props"),
652
+ resolution: z3.number().min(0.5).max(4).default(1).describe("Raster detail: 2 keeps it sharp when scaled up or under a camera zoom (slower)")
653
+ }),
654
+ // JavaScript drawn onto a width × height canvas every frame: function draw(ctx, { frame, t, fps, width, height, props, api }). It runs
655
+ // in the page that renders the video, so unlike every other layer it can do anything JavaScript can: compositions with code layers only
656
+ // render when the caller says it trusts them (--allow-code), and never come from the AI layer or a pasted file. It must be a pure function
657
+ // of its arguments (use api.random(seed), not Math.random) or frames differ between renders.
658
+ baseLayer.extend({
659
+ type: z3.literal("code"),
660
+ code: z3.string().min(1).optional().describe("The JavaScript, inline: defines function draw(ctx, args)"),
661
+ src: z3.string().min(1).optional().describe("A .js file with the same, relative to the composition"),
662
+ width: z3.number().positive(),
663
+ height: z3.number().positive(),
664
+ props: z3.record(z3.string(), z3.unknown()).default({}).describe("Values handed to draw as args.props (they can use {{variables}})"),
665
+ resolution: z3.number().min(0.5).max(4).default(1).describe("Raster detail: 2 keeps it sharp when scaled up or under a camera zoom (slower)")
666
+ }).superRefine((l, ctx) => {
667
+ if (l.code === void 0 === (l.src === void 0)) ctx.addIssue({ code: "custom", path: ["code"], message: 'A code layer needs exactly one of "code" (inline) or "src" (a file)' });
668
+ }),
669
+ // A GLSL fragment shader filling a width × height box: time, resolution and frame are provided (and Shadertoy's iTime, iResolution,
670
+ // iFrame for code written as mainImage), plus each var as a uniform of the same name without the dashes (numbers as float,
671
+ // colours as vec4). It runs on the GPU with no access to anything else.
672
+ baseLayer.extend({
673
+ type: z3.literal("shader"),
674
+ fragment: z3.string().min(1).describe("GLSL ES fragment shader: a main() writing gl_FragColor, or a Shadertoy mainImage(out vec4, in vec2)"),
675
+ width: z3.number().positive(),
676
+ height: z3.number().positive(),
677
+ vars: z3.record(z3.string().regex(cssVariableName, "Variable names start with --"), z3.union([z3.number(), z3.string().regex(colorPattern)])).default({}).describe('Uniforms: { "--speed": 1 } becomes uniform float speed; colours become vec4'),
678
+ resolution: z3.number().min(0.25).max(2).default(1).describe("Render at this fraction of the size and scale up (0.5 is four times faster)")
679
+ }),
680
+ baseLayer.extend({ type: z3.literal("image"), src: z3.string().min(1), objectFit: z3.enum(["cover", "contain", "fill"]).default("cover"), radius: z3.number().nonnegative().default(0).describe("Rounded corners, clipping the picture") }),
681
+ baseLayer.extend({ type: z3.literal("video"), src: z3.string().min(1), objectFit: z3.enum(["cover", "contain", "fill"]).default("cover"), radius: z3.number().nonnegative().default(0).describe("Rounded corners, clipping the picture"), trimStart: z3.number().int().nonnegative().default(0).describe("Frames of the source to skip, at the composition fps"), playbackRate: z3.number().positive().max(16).default(1), loop: z3.boolean().default(false).describe("Restart from the first source frame when the source ends; otherwise hold the last frame"), volume: z3.number().min(0).max(4).default(1).describe("Volume of the clip's own sound; 1 is unchanged; animatable"), muted: z3.boolean().default(false), duck: duckSchema.optional() }),
682
+ baseLayer.extend({ type: z3.literal("audio"), src: z3.string().min(1).refine((src) => !isBuiltinSound(src) || sfxNames.includes(builtinSoundName(src)), { message: `Built-in sounds are ${sfxNames.map((n) => `sfx:${n}`).join(", ")}` }).describe("An audio asset id, a file relative to the composition, or a built-in sound: sfx:click, sfx:tick, sfx:pop, sfx:whoosh, sfx:thump, sfx:ding, sfx:riser"), volume: z3.number().min(0).max(4).default(1).describe("1 is unchanged, 0.5 is half, 2 is double; animatable with keyframes"), duck: duckSchema.optional(), trimStart: z3.number().int().nonnegative().default(0).describe("Frames of the source to skip, at the composition fps"), playbackRate: z3.number().positive().max(16).default(1), loop: z3.boolean().default(false).describe("Restart from the start of the source when it ends; otherwise silence"), fadeInFrames: z3.number().int().nonnegative().default(0), fadeOutFrames: z3.number().int().nonnegative().default(0) }),
683
+ // Captions: shows the line being spoken at each moment. With word timings the current word is highlighted; spoken words keep
684
+ // `color`, words still to come use `upcomingColor`. x, y is the anchor of the first line (centre for textAlign center).
685
+ baseLayer.extend({
686
+ type: z3.literal("captions"),
687
+ src: z3.string().min(1).describe("A captions asset id, or a .srt, .vtt or .json file relative to the composition"),
688
+ cues: z3.array(captionCueSchema).optional().describe("Caption lines given inline instead of a file"),
689
+ mode: z3.enum(["words", "lines"]).default("words").describe("words highlights the current word when word timings exist; lines shows each line in one colour"),
690
+ maxWords: z3.number().int().positive().default(7).describe("Longest line, in words, when grouping word timings"),
691
+ offset: z3.number().default(0).describe("Seconds to shift the captions by (positive = later)"),
692
+ fontSize: z3.number().positive().default(52),
693
+ fontFamily: z3.string().default("Inter"),
694
+ fontWeight: fontWeight.default(700),
695
+ fontStyle: fontStyle.default("normal"),
696
+ letterSpacing: z3.number().default(0),
697
+ lineHeight: z3.number().positive().default(1.3),
698
+ textAlign: z3.enum(["left", "center", "right"]).default("center"),
699
+ maxWidth: z3.number().positive().default(1400),
700
+ color: color.default("#ffffff"),
701
+ highlightColor: color.default("#facc15"),
702
+ emphasis: z3.array(z3.string()).default([]).describe("Words to stand out (any case, punctuation ignored), drawn in emphasisColor once said"),
703
+ emphasisColor: color.optional().describe("Colour of emphasis words; default: highlightColor"),
704
+ dropFillers: z3.boolean().default(true).describe("Leave out filler words (um, uh, erm\u2026) and words said twice in a row"),
705
+ upcomingColor: color.default("rgba(255,255,255,0.55)"),
706
+ background: color.default("rgba(0,0,0,0.55)").describe('Box behind the text; "transparent" for none'),
707
+ padding: z3.number().nonnegative().default(18),
708
+ radius: z3.number().nonnegative().default(14),
709
+ ...strokeFields,
710
+ lineJoin: strokeFields.lineJoin.removeDefault().default("round")
711
+ }),
712
+ baseLayer.extend({ type: z3.literal("rect"), fill: paint.default("#000000"), radius: z3.number().nonnegative().default(0), ...strokeFields }),
713
+ baseLayer.extend({ type: z3.literal("circle"), fill: paint.default("#000000"), ...strokeFields }),
714
+ baseLayer.extend({
715
+ type: z3.literal("path"),
716
+ d: z3.string().min(1).describe('SVG path data, in pixels relative to x, y, e.g. "M0 0 L200 0 L100 150 Z"'),
717
+ fill: paint.optional().describe("Leave out for an outline-only path"),
718
+ fillRule: z3.enum(["nonzero", "evenodd"]).default("nonzero"),
719
+ ...strokeFields,
720
+ trimStart: z3.number().min(0).max(1).default(0).describe("Fraction of the outline hidden at its start"),
721
+ trimEnd: z3.number().min(0).max(1).default(1).describe("Fraction of the outline drawn up to; animate 0 \u2192 1 to draw the line on")
722
+ }),
723
+ baseLayer.extend({
724
+ type: z3.literal("group"),
725
+ children: z3.array(z3.lazy(() => layerSchema)).default([]),
726
+ masked: z3.object({ mode: z3.enum(["luma", "alpha"]).default("luma"), invert: z3.boolean().default(false) }).optional().describe("The group's last child is a matte: the other children show only where it is white (luma) or opaque (alpha). Written for you by the matte property."),
727
+ clipPath: z3.string().min(1).optional().describe("SVG path data in the group's pixels; children are only visible inside it"),
728
+ layout: z3.object({
729
+ direction: z3.enum(["row", "column"]).default("row"),
730
+ gap: z3.number().nonnegative().default(0),
731
+ padding: z3.number().nonnegative().default(0),
732
+ align: z3.enum(["start", "center", "end"]).default("start").describe("Across the direction: top, middle or bottom of a row; left, centre or right of a column"),
733
+ justify: z3.enum(["start", "center", "end", "space-between"]).default("start").describe("Along the direction, within the group's width (row) or height (column) when set")
734
+ }).optional().describe("Place the children one after another, measured as drawn (text included); each child's own x and y then offset it from its place")
735
+ })
736
+ ]);
737
+ var cameraSchema = z3.object({
738
+ x: z3.number().optional(),
739
+ y: z3.number().optional(),
740
+ zoom: z3.number().positive().default(1),
741
+ rotation: z3.number().default(0),
742
+ shake: z3.number().nonnegative().default(0).describe("Wobble in pixels, the same in every render"),
743
+ animations: z3.array(z3.object({ property: z3.enum(["x", "y", "zoom", "rotation", "shake"]), keyframes: z3.array(z3.object({ frame: z3.number().int().nonnegative(), value: z3.number(), easing: easingSchema.optional() })).min(1), repeat: z3.number().int().nonnegative().optional(), reverse: z3.boolean().optional() })).default([])
744
+ });
745
+ var sceneSchema = z3.object({ grade: gradeSchema.optional().describe("Colour grading for the whole scene (fixed layers excluded)."), id: z3.string().min(1), name: z3.string().min(1), startFrame: z3.number().int().nonnegative().default(0), overlap: z3.number().int().nonnegative().optional().describe("Frames this scene overlaps the previous one when startFrame is left out"), durationFrames: z3.number().int().positive(), metadata: z3.record(z3.string(), z3.unknown()).default({}), transitionIn: transitionSchema.optional(), transitionOut: transitionSchema.optional(), camera: cameraSchema.optional().describe("Moves, zooms and turns the view of the whole scene; layers with fixed: true stay put"), layers: z3.array(layerSchema).default([]), assetIds: z3.array(z3.string()).default([]) });
746
+ var assetSchema = z3.object({ id: z3.string().min(1), type: z3.enum(["image", "video", "audio", "font", "svg", "captions", "lottie"]), src: z3.string().min(1), family: z3.string().min(1).optional().describe("Font assets: the family name text layers use"), weight: fontWeight.optional().describe("Font assets: the weight this file provides (default 400)"), style: fontStyle.optional().describe("Font assets: normal or italic (default normal)"), metadata: z3.record(z3.string(), z3.unknown()).default({}) });
747
+ var tempoSchema = z3.object({ bpm: z3.number().positive().max(400), offset: z3.number().default(0).describe("Seconds from the start of the video to the first beat"), beatsPerBar: z3.number().int().positive().default(4) });
748
+ function beatFunctions(raw) {
749
+ const tempo2 = tempoSchema.safeParse(raw?.tempo), fps = Number(raw?.fps);
750
+ const need = (name2) => () => {
751
+ throw new Error(`${name2}() needs a top-level "tempo": { "bpm": \u2026 } and a numeric fps`);
752
+ };
753
+ if (!tempo2.success || !(fps > 0)) return { beat: need("beat"), bar: need("bar"), beats: need("beats") };
754
+ const { bpm, offset, beatsPerBar } = tempo2.data, at = (n) => Math.round((offset + n * 60 / bpm) * fps), number = (v, name2) => {
755
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`${name2}() takes numbers`);
756
+ return v;
757
+ };
758
+ return { beat: (n) => at(number(n, "beat")), bar: (n, b = 0) => at(number(n, "bar") * beatsPerBar + number(b, "bar")), beats: (n) => Math.round(number(n, "beats") * 60 / bpm * fps) };
759
+ }
760
+ var compositionSchema = z3.object({ $schema: z3.string().optional(), version: z3.literal("1.0"), id: z3.string().min(1), name: z3.string().min(1), width: z3.number().int().positive(), height: z3.number().int().positive(), fps: z3.number().int().positive().max(240), durationFrames: z3.union([z3.number().int().positive(), z3.literal("auto")]).describe('"auto" ends the video when its last scene ends').transform((v, ctx) => {
761
+ if (v === "auto") {
762
+ ctx.addIssue({ code: "custom", message: '"auto" is resolved by loadComposition; parseComposition needs a number' });
763
+ return z3.NEVER;
764
+ }
765
+ return v;
766
+ }), variables: variablesSchema.default({}).describe("Template inputs; use them as {{name}} in any string"), components: componentsSchema.default({}).describe('Reusable layer sets, placed with { "use": name, "with": { \u2026 } }'), assets: z3.array(assetSchema).default([]), scenes: z3.array(sceneSchema).min(1), background: paint.default("#101827").describe("Colour or gradient behind all layers (ignored when export.transparent is true)"), motionBlur: z3.object({ samples: z3.number().int().min(2).max(32).default(8).describe("Sub-frames averaged per frame: more is smoother and slower"), shutter: z3.number().gt(0).max(360).default(180).describe("Shutter angle in degrees: 180 blurs over half a frame, the film look; 360 over a whole frame") }).optional().describe("Blur fast motion by averaging sub-frames (renders only; the live preview shows it sharp). Each frame takes samples times as long to draw"), tempo: tempoSchema.optional().describe("The music's tempo, for beat(n), bar(n) and beats(n) in expressions"), grain: z3.number().min(0).max(1).default(0).describe("Film grain over the whole frame, such as 0.06: it hides banding in dark gradients. Different each frame, the same in every render; not applied to transparent output"), export: exportSchema.default({ format: "mp4", codec: "h264", transparent: false, scale: 1, audioVolume: 1 }) }).superRefine((value, ctx) => {
767
+ for (const scene of value.scenes) if (scene.startFrame + scene.durationFrames > value.durationFrames) ctx.addIssue({ code: "custom", path: ["scenes"], message: `Scene ${scene.id} exceeds composition duration` });
768
+ });
769
+ function expandSceneGrades(input) {
770
+ const raw = input;
771
+ if (!raw || typeof raw !== "object" || !Array.isArray(raw.scenes)) return input;
772
+ return { ...raw, scenes: raw.scenes.map((scene) => {
773
+ if (!scene || typeof scene !== "object" || !scene.grade || !Array.isArray(scene.layers)) return scene;
774
+ const { grade, ...rest } = scene, still = scene.layers.filter((layer) => layer?.fixed), moving = scene.layers.filter((layer) => !layer?.fixed);
775
+ return { ...rest, layers: [{ id: `${scene.id}-graded`, type: "group", x: 0, y: 0, grade, children: moving }, ...still] };
776
+ }) };
777
+ }
778
+ var pictureFile = /\.(png|jpe?g|webp|gif|bmp)$/i;
779
+ function expandMattes(input) {
780
+ const raw = input;
781
+ if (!raw || typeof raw !== "object" || !Array.isArray(raw.scenes)) return input;
782
+ const wrap = (layer) => {
783
+ if (!layer || typeof layer !== "object") return layer;
784
+ const next = Array.isArray(layer.children) ? { ...layer, children: layer.children.map(wrap) } : layer;
785
+ const matte = next.matte;
786
+ if (!matte || typeof matte !== "object" || typeof matte.src !== "string") return next;
787
+ const { matte: _matte, zIndex, fixed, ...inner } = next;
788
+ const image = (matte.type ?? (pictureFile.test(matte.src) ? "image" : "video")) === "image", follows = inner.type === "video";
789
+ const matteLayer = {
790
+ id: `${inner.id}-matte`,
791
+ type: image ? "image" : "video",
792
+ src: matte.src,
793
+ x: 0,
794
+ y: 0,
795
+ width: raw.width,
796
+ height: raw.height,
797
+ objectFit: "cover",
798
+ startFrame: inner.startFrame ?? 0,
799
+ ...inner.endFrame !== void 0 ? { endFrame: inner.endFrame } : {},
800
+ ...image ? {} : { trimStart: matte.trimStart ?? (follows ? inner.trimStart ?? 0 : 0), playbackRate: matte.playbackRate ?? (follows ? inner.playbackRate ?? 1 : 1), loop: follows ? !!inner.loop : false, muted: true, volume: 0 }
801
+ };
802
+ return { id: `${inner.id}-masked`, type: "group", x: 0, y: 0, ...zIndex !== void 0 ? { zIndex } : {}, ...fixed ? { fixed } : {}, masked: { mode: matte.mode ?? "luma", invert: !!matte.invert }, children: [inner, matteLayer] };
803
+ };
804
+ return { ...raw, scenes: raw.scenes.map((scene) => scene && Array.isArray(scene.layers) ? { ...scene, layers: scene.layers.map(wrap) } : scene) };
805
+ }
806
+ function parseComposition(input) {
807
+ return compositionSchema.parse(expandMattes(expandSceneGrades(input)));
808
+ }
809
+ var canvasNames = (input) => {
810
+ const raw = input ?? {};
811
+ const [width, height, fps] = [raw.width, raw.height, raw.fps];
812
+ return typeof width === "number" && typeof height === "number" ? { canvas: { width, height, fps: typeof fps === "number" ? fps : 30 } } : {};
813
+ };
814
+ function sequenceScenes(expanded) {
815
+ if (!expanded || typeof expanded !== "object" || !Array.isArray(expanded.scenes)) return expanded;
816
+ const raw = expanded;
817
+ let end = 0, latest = 0;
818
+ const scenes = raw.scenes.map((scene, i) => {
819
+ if (!scene || typeof scene !== "object") return scene;
820
+ let start = scene.startFrame;
821
+ if (start === void 0) start = i === 0 ? 0 : Math.max(0, end - (typeof scene.overlap === "number" ? scene.overlap : scene.transitionIn?.durationFrames ?? 0));
822
+ if (typeof start === "number" && typeof scene.durationFrames === "number") {
823
+ end = start + scene.durationFrames;
824
+ latest = Math.max(latest, end);
825
+ }
826
+ return { ...scene, startFrame: start };
827
+ });
828
+ return { ...raw, scenes, durationFrames: raw.durationFrames === "auto" || raw.durationFrames === void 0 ? latest || raw.durationFrames : raw.durationFrames };
829
+ }
830
+ function loadComposition(input, values = {}, options = {}) {
831
+ const declarations = variablesSchema.parse(input?.variables ?? {});
832
+ return parseComposition(sequenceScenes(substituteVariables(input, resolveVariables(declarations, values), { ...beatFunctions(input), ...canvasNames(input), ...options.media ? { media: options.media } : {} })));
833
+ }
834
+ var compositionJsonSchema = z3.toJSONSchema(compositionSchema, { target: "draft-2020-12", io: "input" });
835
+ var describeError = (error) => error instanceof z3.ZodError ? z3.prettifyError(error) : error instanceof Error ? error.message : String(error);
836
+
837
+ // packages/core/src/media.ts
838
+ import { spawn as spawn2 } from "node:child_process";
839
+ import { mkdir, readFile as readFile2, stat as stat2, writeFile } from "node:fs/promises";
840
+ import { join as join3 } from "node:path";
841
+ import { createHash } from "node:crypto";
842
+
843
+ // packages/animation/src/index.ts
844
+ var back = 1.70158;
845
+ var roleCurves = { land: [0.16, 1, 0.3, 1], snap: [0.05, 0.9, 0.1, 1], glide: [0.25, 0.8, 0.35, 1], pop: [0.34, 1.45, 0.64, 1], move: [0.76, 0, 0.24, 1], exit: [0.5, 0, 0.75, 0], drift: [0.35, 0.15, 0.65, 0.85] };
846
+ var springCurve = (t) => 1 - Math.exp(-7 * t) * Math.cos(3 * Math.PI * t);
847
+ var springEnd = springCurve(1);
848
+ function cubicBezier(x1, y1, x2, y2, t) {
849
+ const bez = (a, b, s2) => 3 * (1 - s2) * (1 - s2) * s2 * a + 3 * (1 - s2) * s2 * s2 * b + s2 * s2 * s2;
850
+ const slope = (a, b, s2) => 3 * (1 - s2) * (1 - s2) * a + 6 * (1 - s2) * s2 * (b - a) + 3 * s2 * s2 * (1 - b);
851
+ let s = t;
852
+ for (let i = 0; i < 8; i++) {
853
+ const error = bez(x1, x2, s) - t, d = slope(x1, x2, s);
854
+ if (Math.abs(error) < 1e-7) return bez(y1, y2, s);
855
+ if (Math.abs(d) < 1e-6) break;
856
+ s -= error / d;
857
+ }
858
+ let lo = 0, hi = 1;
859
+ s = t;
860
+ for (let i = 0; i < 60; i++) {
861
+ const x = bez(x1, x2, s);
862
+ if (Math.abs(x - t) < 1e-7) break;
863
+ if (x < t) lo = s;
864
+ else hi = s;
865
+ s = (lo + hi) / 2;
866
+ }
867
+ return bez(y1, y2, s);
868
+ }
869
+ function ease(t, kind = "linear") {
870
+ if (t <= 0) return 0;
871
+ if (t >= 1) return 1;
872
+ if (typeof kind !== "string") return cubicBezier(kind[0], kind[1], kind[2], kind[3], t);
873
+ switch (kind) {
874
+ case "easeIn":
875
+ return t * t;
876
+ case "easeOut":
877
+ return 1 - (1 - t) * (1 - t);
878
+ case "easeInOut":
879
+ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
880
+ case "easeInCubic":
881
+ return t * t * t;
882
+ case "easeOutCubic":
883
+ return 1 - Math.pow(1 - t, 3);
884
+ case "easeInOutCubic":
885
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
886
+ case "easeInQuart":
887
+ return t * t * t * t;
888
+ case "easeOutQuart":
889
+ return 1 - Math.pow(1 - t, 4);
890
+ case "easeInOutQuart":
891
+ return t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2;
892
+ case "easeInExpo":
893
+ return Math.pow(2, 10 * t - 10);
894
+ case "easeOutExpo":
895
+ return 1 - Math.pow(2, -10 * t);
896
+ case "easeInOutExpo":
897
+ return t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2;
898
+ case "easeInBack":
899
+ return (back + 1) * t * t * t - back * t * t;
900
+ case "easeOutBack":
901
+ return 1 + (back + 1) * Math.pow(t - 1, 3) + back * Math.pow(t - 1, 2);
902
+ case "spring":
903
+ return springCurve(t) / springEnd;
904
+ case "hold":
905
+ return 0;
906
+ default: {
907
+ const c = roleCurves[kind];
908
+ return c ? cubicBezier(c[0], c[1], c[2], c[3], t) : t;
909
+ }
910
+ }
911
+ }
912
+ function parseColor(value) {
913
+ const v = value.trim().toLowerCase();
914
+ if (v === "transparent") return [0, 0, 0, 0];
915
+ const hex = /^#([0-9a-f]{3,8})$/.exec(v);
916
+ if (hex && [3, 4, 6, 8].includes(hex[1].length)) {
917
+ const h = hex[1], full = h.length <= 4 ? [...h].map((c) => c + c).join("") : h;
918
+ const n = (i) => parseInt(full.slice(i, i + 2), 16);
919
+ return [n(0), n(2), n(4), full.length === 8 ? n(6) / 255 : 1];
920
+ }
921
+ const fn = /^rgba?\(([^)]+)\)$/.exec(v);
922
+ if (fn) {
923
+ const parts = fn[1].split(/[\s,/]+/).filter(Boolean).map((p, i) => p.endsWith("%") ? parseFloat(p) * (i === 3 ? 0.01 : 2.55) : parseFloat(p));
924
+ if ((parts.length === 3 || parts.length === 4) && parts.every(Number.isFinite)) return [parts[0], parts[1], parts[2], parts[3] ?? 1];
925
+ }
926
+ throw new Error(`Cannot animate colour "${value}"`);
927
+ }
928
+ function mixColors(from, to, t) {
929
+ const a = parseColor(from), b = parseColor(to), c = a.map((x, i) => x + (b[i] - x) * t);
930
+ return `rgba(${Math.round(c[0])},${Math.round(c[1])},${Math.round(c[2])},${Math.round(c[3] * 1e4) / 1e4})`;
931
+ }
932
+ function animationSegment(animation2, frame) {
933
+ const keys = animation2.keyframes;
934
+ const duration = keys.at(-1).frame;
935
+ let f = duration === 0 ? 0 : frame;
936
+ if (animation2.repeat !== void 0 && duration > 0) {
937
+ const cycle = Math.min(Math.floor(f / duration), animation2.repeat);
938
+ f = f - cycle * duration;
939
+ if (animation2.reverse && cycle % 2) f = duration - f;
940
+ }
941
+ if (f <= keys[0].frame) return { from: keys[0].value, to: keys[0].value, t: 0 };
942
+ if (f >= keys.at(-1).frame) return { from: keys.at(-1).value, to: keys.at(-1).value, t: 1 };
943
+ const i = keys.findIndex((k) => k.frame >= f), from = keys[i - 1], to = keys[i];
944
+ return { from: from.value, to: to.value, t: ease((f - from.frame) / (to.frame - from.frame), to.easing ?? "linear") };
945
+ }
946
+ function evaluateAnimation(animation2, frame) {
947
+ const keys = animation2.keyframes;
948
+ const duration = keys.at(-1).frame;
949
+ let f = duration === 0 ? 0 : frame;
950
+ if (animation2.repeat !== void 0 && duration > 0) {
951
+ const cycle = Math.min(Math.floor(f / duration), animation2.repeat);
952
+ f = f - cycle * duration;
953
+ if (animation2.reverse && cycle % 2) f = duration - f;
954
+ }
955
+ if (f <= keys[0].frame) return keys[0].value;
956
+ if (f >= keys.at(-1).frame) return keys.at(-1).value;
957
+ const i = keys.findIndex((k) => k.frame >= f), from = keys[i - 1], to = keys[i], t = ease((f - from.frame) / (to.frame - from.frame), to.easing ?? "linear");
958
+ return typeof from.value === "string" || typeof to.value === "string" ? mixColors(String(from.value), String(to.value), t) : from.value + (to.value - from.value) * t;
959
+ }
960
+
961
+ // packages/timeline/src/index.ts
962
+ function splitPresetAnimations(preset, duration, fontSize) {
963
+ const d = duration, fade = Math.max(1, Math.round(d * 0.6)), em = fontSize;
964
+ const appear = { property: "opacity", keyframes: [{ frame: 0, value: 0 }, { frame: fade, value: 1 }] };
965
+ switch (preset) {
966
+ case "fade":
967
+ return [{ property: "opacity", keyframes: [{ frame: 0, value: 0 }, { frame: d, value: 1, easing: "glide" }] }];
968
+ case "pop":
969
+ return [{ property: "opacity", keyframes: [{ frame: 0, value: 0 }, { frame: Math.max(1, Math.round(d * 0.35)), value: 1 }] }, { property: "scale", keyframes: [{ frame: 0, value: 0.6 }, { frame: d, value: 1, easing: "pop" }] }];
970
+ case "blur":
971
+ return [appear, { property: "blur", keyframes: [{ frame: 0, value: 0.25 * em }, { frame: d, value: 0, easing: "land" }] }, { property: "scale", keyframes: [{ frame: 0, value: 1.08 }, { frame: d, value: 1, easing: "land" }] }];
972
+ case "slide":
973
+ return [appear, { property: "x", keyframes: [{ frame: 0, value: 0.6 * em }, { frame: d, value: 0, easing: "land" }] }];
974
+ case "drop":
975
+ return [appear, { property: "y", keyframes: [{ frame: 0, value: -0.5 * em }, { frame: d, value: 0, easing: "land" }] }];
976
+ case "typewriter":
977
+ return [{ property: "opacity", keyframes: [{ frame: 0, value: 0 }, { frame: 1, value: 1, easing: "hold" }] }];
978
+ default:
979
+ return [appear, { property: "y", keyframes: [{ frame: 0, value: 0.45 * em }, { frame: d, value: 0, easing: "land" }] }];
980
+ }
981
+ }
982
+ function splitCount(layer) {
983
+ const text = layer.spans?.length ? layer.spans.map((s) => s.text).join("") : String(layer.content ?? "");
984
+ return Math.max(1, layer.split?.by === "letters" ? Array.from(text.replace(/\s/g, "")).length : text.split(/\s+/).filter(Boolean).length);
985
+ }
986
+ function splitUnits(layer, own) {
987
+ const split = layer.split, animations = split.animations?.length ? split.animations : splitPresetAnimations(split.preset, split.duration, layer.fontSize);
988
+ return Array.from({ length: splitCount(layer) }, (_, step) => Object.fromEntries(animations.map((a) => [a.property, evaluateAnimation(a, own - step * split.stagger)])));
989
+ }
990
+ var frameToSeconds = (frame, fps) => frame / fps;
991
+ var secondsToFrame = (seconds3, fps) => Math.round(seconds3 * fps);
992
+ function evaluateLayers(layers, frame) {
993
+ return layers.filter((layer) => layer.visible && frame >= layer.startFrame && (layer.endFrame === void 0 || frame < layer.endFrame)).map((layer) => {
994
+ const own = frame - layer.startFrame;
995
+ const evaluated = Object.fromEntries(layer.animations.filter((a) => a.property !== "d").map((a) => [a.property, evaluateAnimation(a, own)]));
996
+ const shape = layer.animations.find((a) => a.property === "d");
997
+ return { ...layer, localFrame: frame, evaluated, ...shape ? { morph: animationSegment(shape, own) } : {}, ...layer.type === "group" ? { children: evaluateLayers(layer.children, own) } : {}, ...layer.type === "text" && layer.split ? { units: splitUnits(layer, own) } : {} };
998
+ }).sort((a, b) => a.zIndex - b.zIndex);
999
+ }
1000
+ function transitionState(effects, width, height) {
1001
+ let x = 0, y = 0, opacity = 1, scale = 1, clip = { x: 0, y: 0, width, height };
1002
+ let clipped = false, clipPath, blur = 0, overlay;
1003
+ for (const { transition, progress } of effects) {
1004
+ const p = ease(progress, transition.easing), dir = transition.direction;
1005
+ if (transition.type === "fade") opacity *= p;
1006
+ else if (transition.type === "zoom") {
1007
+ opacity *= p;
1008
+ scale *= 0.85 + 0.15 * p;
1009
+ } else if (transition.type === "iris") {
1010
+ const cx = transition.x ?? width / 2, cy = transition.y ?? height / 2, r = Math.max(0.01, p * Math.hypot(Math.max(cx, width - cx), Math.max(cy, height - cy)));
1011
+ clipPath = `M${cx - r} ${cy}A${r} ${r} 0 1 0 ${cx + r} ${cy}A${r} ${r} 0 1 0 ${cx - r} ${cy}Z`;
1012
+ } else if (transition.type === "dip") {
1013
+ if (progress < 0.5) opacity = 0;
1014
+ overlay = { color: transition.color, opacity: 1 - Math.abs(2 * progress - 1) };
1015
+ } else if (transition.type === "blur") {
1016
+ opacity *= Math.min(1, p * 1.5);
1017
+ blur = Math.max(blur, (1 - p) * 0.02 * Math.max(width, height));
1018
+ } else if (transition.type === "slide" || transition.type === "push") {
1019
+ if (dir === "left") x -= (1 - p) * width;
1020
+ else if (dir === "right") x += (1 - p) * width;
1021
+ else if (dir === "top") y -= (1 - p) * height;
1022
+ else y += (1 - p) * height;
1023
+ } else {
1024
+ clipped = true;
1025
+ const visible = dir === "left" ? { x: 0, y: 0, width: p * width, height } : dir === "right" ? { x: (1 - p) * width, y: 0, width: p * width, height } : dir === "top" ? { x: 0, y: 0, width, height: p * height } : { x: 0, y: (1 - p) * height, width, height: p * height };
1026
+ const left = Math.max(clip.x, visible.x), top = Math.max(clip.y, visible.y);
1027
+ clip = { x: left, y: top, width: Math.max(0, Math.min(clip.x + clip.width, visible.x + visible.width) - left), height: Math.max(0, Math.min(clip.y + clip.height, visible.y + visible.height) - top) };
1028
+ }
1029
+ }
1030
+ return { x, y, opacity, scale, clip: clipped ? clip : void 0, clipPath, blur, overlay };
1031
+ }
1032
+ var wobble = (t, phase) => (Math.sin(t * 1.31 + phase) + 0.6 * Math.sin(t * 2.93 + phase * 2.1) + 0.3 * Math.sin(t * 5.37 + phase * 3.7)) / 1.9;
1033
+ function cameraState(camera, frame, width, height) {
1034
+ const value = (name2, fallback) => {
1035
+ const a = camera.animations.find((x) => x.property === name2);
1036
+ return a ? Number(evaluateAnimation(a, frame)) : fallback;
1037
+ };
1038
+ const shake = value("shake", camera.shake);
1039
+ return { x: value("x", camera.x ?? width / 2) + shake * wobble(frame * 0.9, 0.3), y: value("y", camera.y ?? height / 2) + shake * wobble(frame * 0.9, 2.1), zoom: value("zoom", camera.zoom), rotation: value("rotation", camera.rotation) + shake * 0.04 * wobble(frame * 0.7, 4.2) };
1040
+ }
1041
+ function evaluateFrame(composition, frame) {
1042
+ if (!Number.isFinite(frame) || frame < 0 || frame >= composition.durationFrames) return [];
1043
+ const { width, height } = composition;
1044
+ const pushes = composition.scenes.map((scene, index) => ({ scene, index })).filter(({ scene }) => scene.transitionIn?.type === "push" && frame >= scene.startFrame && frame < scene.startFrame + scene.transitionIn.durationFrames).map(({ scene, index }) => {
1045
+ const t = scene.transitionIn, p = ease(Math.min(1, (frame - scene.startFrame) / t.durationFrames), t.easing), dir = t.direction;
1046
+ return { index, dx: dir === "left" ? p * width : dir === "right" ? -p * width : 0, dy: dir === "top" ? p * height : dir === "bottom" ? -p * height : 0 };
1047
+ });
1048
+ return composition.scenes.flatMap((scene, index) => {
1049
+ if (frame < scene.startFrame || frame >= scene.startFrame + scene.durationFrames) return [];
1050
+ const localFrame = frame - scene.startFrame;
1051
+ let layers = evaluateLayers(scene.layers, localFrame);
1052
+ if (scene.camera) {
1053
+ const cam = cameraState(scene.camera, localFrame, width, height), moving = layers.filter((l) => !l.fixed), fixed = layers.filter((l) => l.fixed);
1054
+ layers = [{ id: `camera:${scene.id}`, type: "group", startFrame: 0, x: width / 2 - cam.x, y: height / 2 - cam.y, originX: cam.x, originY: cam.y, scale: cam.zoom, rotation: -cam.rotation, opacity: 1, zIndex: 0, visible: true, fixed: false, animations: [], children: moving, evaluated: {}, localFrame }, ...fixed];
1055
+ }
1056
+ const effects = [scene.transitionIn && { transition: scene.transitionIn, progress: Math.min(1, localFrame / scene.transitionIn.durationFrames) }, scene.transitionOut && { transition: scene.transitionOut, progress: Math.min(1, (scene.durationFrames - localFrame) / scene.transitionOut.durationFrames) }].filter((e) => !!e && e.progress < 1);
1057
+ const pushed = pushes.filter((p) => p.index > index).reduce((o, p) => ({ dx: o.dx + p.dx, dy: o.dy + p.dy }), { dx: 0, dy: 0 });
1058
+ if (!effects.length && !pushed.dx && !pushed.dy) return layers;
1059
+ const state = transitionState(effects, width, height);
1060
+ state.x += pushed.dx;
1061
+ state.y += pushed.dy;
1062
+ const overlay = state.overlay && state.overlay.opacity > 0 ? [{ id: `dip:${scene.id}`, type: "rect", startFrame: 0, x: 0, y: 0, width, height, fill: state.overlay.color, opacity: state.overlay.opacity, scale: 1, rotation: 0, originX: 0, originY: 0, zIndex: 0, visible: true, animations: [], evaluated: {}, localFrame }] : [];
1063
+ return [{ id: `scene:${scene.id}`, type: "group", startFrame: 0, x: state.x, y: state.y, opacity: state.opacity, scale: state.scale, rotation: 0, originX: width / 2, originY: height / 2, zIndex: 0, visible: true, animations: [], children: layers, clip: state.clip, clipPath: state.clipPath, blur: state.blur, evaluated: {}, localFrame }, ...overlay];
1064
+ });
1065
+ }
1066
+ function videoFrameIndex(layer, ownFrame, frameCount) {
1067
+ if (frameCount < 1) throw new Error("Video has no frames");
1068
+ const frame = Math.max(0, Math.floor(layer.trimStart + ownFrame * layer.playbackRate + 1e-9));
1069
+ return layer.loop ? frame % frameCount : Math.min(frame, frameCount - 1);
1070
+ }
1071
+ function layerWindows(composition, include) {
1072
+ const out = [];
1073
+ const walk = (layers, offset, limit) => {
1074
+ for (const layer of layers) {
1075
+ if (!layer.visible) continue;
1076
+ const start = offset + layer.startFrame, end = Math.min(limit, layer.endFrame === void 0 ? Infinity : offset + layer.endFrame);
1077
+ if (end <= start) continue;
1078
+ if (layer.type === "group") walk(layer.children, start, end);
1079
+ else if (include(layer)) out.push({ layer, startFrame: start, endFrame: end });
1080
+ }
1081
+ };
1082
+ for (const scene of composition.scenes) walk(scene.layers, scene.startFrame, Math.min(scene.startFrame + scene.durationFrames, composition.durationFrames));
1083
+ return out;
1084
+ }
1085
+ var audioWindows = (composition) => layerWindows(composition, (layer) => layer.type === "audio" ? layer.volume > 0 : layer.type === "video" && !layer.muted && layer.volume > 0);
1086
+ function soundIntervals(composition, media = {}) {
1087
+ const fps = composition.fps;
1088
+ return audioWindows(composition).map(({ layer, startFrame, endFrame }) => {
1089
+ const from = startFrame / fps, to = endFrame / fps, info = media[layer.src];
1090
+ if (!info) return { layer, intervals: [[from, to]] };
1091
+ const trim = (layer.trimStart ?? 0) / fps, rate = layer.playbackRate ?? 1;
1092
+ const intervals = info.segments.map((s) => [from + (s.start - trim) / rate, from + (s.end - trim) / rate]).map(([a, b]) => [Math.max(a, from), Math.min(b, to)]).filter(([a, b]) => b > a);
1093
+ return { layer, intervals };
1094
+ });
1095
+ }
1096
+ function volumeEnvelope(options) {
1097
+ const { layer, fps, start, duration, duck } = options;
1098
+ const animation2 = (layer.animations ?? []).find((a) => a.property === "volume");
1099
+ const triggers = duck ? options.others.filter((o) => o.layer !== layer && (!duck.by || duck.by.includes(o.layer.id))).flatMap((o) => o.intervals).map(([a, b]) => [a - start, b - start]).filter(([a, b]) => b > 0 && a < duration).sort((x, y) => x[0] - y[0]) : [];
1100
+ if (!animation2 && !triggers.length) return void 0;
1101
+ const attack = Math.max(1e-3, (duck?.attackFrames ?? 0) / fps), release = Math.max(1e-3, (duck?.releaseFrames ?? 0) / fps);
1102
+ const merged = [];
1103
+ for (const [a, b] of triggers) {
1104
+ const last = merged.at(-1);
1105
+ if (last && a - last[1] < attack + release) last[1] = Math.max(last[1], b);
1106
+ else merged.push([a, b]);
1107
+ }
1108
+ const duckAt = (t) => merged.reduce((f, [a, b]) => Math.min(f, t >= a && t <= b ? duck.amount : t < a && t > a - attack ? 1 - (1 - duck.amount) * (t - (a - attack)) / attack : t > b && t < b + release ? duck.amount + (1 - duck.amount) * (t - b) / release : 1), 1);
1109
+ const volumeAt = (t) => animation2 ? Number(evaluateAnimation(animation2, t * fps)) : layer.volume ?? 1;
1110
+ const times = /* @__PURE__ */ new Set([0, duration]);
1111
+ if (animation2) for (let k = 0; k <= Math.ceil(duration * fps); k++) times.add(Math.min(duration, k / fps));
1112
+ for (const [a, b] of merged) for (const t of [a - attack, a, b, b + release]) if (t > 0 && t < duration) times.add(t);
1113
+ const points = [...times].sort((a, b) => a - b).map((t) => [t, Math.max(0, volumeAt(t) * duckAt(t))]);
1114
+ return points.filter((p, i) => {
1115
+ const prev = points[i - 1], next = points[i + 1];
1116
+ if (!prev || !next) return true;
1117
+ const expected = prev[1] + (next[1] - prev[1]) * (p[0] - prev[0]) / (next[0] - prev[0]);
1118
+ return Math.abs(expected - p[1]) > 1e-4;
1119
+ });
1120
+ }
1121
+
1122
+ // packages/assets/src/index.ts
1123
+ import { readFile } from "node:fs/promises";
1124
+ import { extname, isAbsolute, resolve } from "node:path";
1125
+
1126
+ // packages/assets/src/cache.ts
1127
+ import { readdir, rm, stat, utimes } from "node:fs/promises";
1128
+ import { tmpdir } from "node:os";
1129
+ import { join } from "node:path";
1130
+ var cacheKinds = ["media", "video", "sfx"];
1131
+ var cacheRoot = () => process.env.OVE_CACHE_DIR ?? join(tmpdir(), "ove-video-cache");
1132
+ var cacheDir = (kind) => join(cacheRoot(), kind);
1133
+ var touchCacheEntry = (path) => {
1134
+ const now = /* @__PURE__ */ new Date();
1135
+ return utimes(path, now, now).catch(() => {
1136
+ });
1137
+ };
1138
+ var sizeOf = async (path) => {
1139
+ const info = await stat(path).catch(() => void 0);
1140
+ if (!info) return 0;
1141
+ if (!info.isDirectory()) return info.size;
1142
+ let total = 0;
1143
+ for (const name2 of await readdir(path).catch(() => [])) total += await sizeOf(join(path, name2));
1144
+ return total;
1145
+ };
1146
+ var legacyEntry = /^[0-9a-f]{32}(\.partial-[0-9a-f-]{36})?$/;
1147
+ var entries = async (kind) => {
1148
+ const dir = kind === "other" ? cacheRoot() : cacheDir(kind);
1149
+ const names = (await readdir(dir).catch(() => [])).filter((name2) => kind !== "other" || legacyEntry.test(name2));
1150
+ return Promise.all(names.map(async (name2) => {
1151
+ const path = join(dir, name2), info = await stat(path).catch(() => void 0);
1152
+ return { path, used: info?.mtimeMs ?? 0 };
1153
+ }));
1154
+ };
1155
+ async function cacheUsage() {
1156
+ const kinds = await Promise.all([...cacheKinds, "other"].map(async (kind) => {
1157
+ const list = await entries(kind);
1158
+ let bytes = 0;
1159
+ for (const e of list) bytes += await sizeOf(e.path);
1160
+ return { kind, entries: list.length, bytes };
1161
+ }));
1162
+ return { root: cacheRoot(), kinds, bytes: kinds.reduce((n, k) => n + k.bytes, 0) };
1163
+ }
1164
+ async function cleanCache(options = {}) {
1165
+ const cutoff = options.olderThanMs === void 0 ? Infinity : Date.now() - options.olderThanMs;
1166
+ let removed = 0, bytes = 0;
1167
+ for (const kind of options.kinds ?? [...cacheKinds, "other"]) for (const e of await entries(kind)) if (e.used < cutoff) {
1168
+ bytes += await sizeOf(e.path);
1169
+ await rm(e.path, { recursive: true, force: true });
1170
+ removed++;
1171
+ }
1172
+ return { entries: removed, bytes };
1173
+ }
1174
+
1175
+ // packages/assets/src/index.ts
1176
+ var mimeTypes = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf", ".otf": "font/otf" };
1177
+ function validateAssetPath(src) {
1178
+ if (!src || src.includes("\0")) throw new Error("Invalid asset source");
1179
+ }
1180
+ var isUrl = (src) => /^(https?|data|blob):/i.test(src);
1181
+ function resolveAssetSource(composition, src, baseDir) {
1182
+ validateAssetPath(src);
1183
+ const target = composition.assets.find((a) => a.id === src)?.src ?? src;
1184
+ validateAssetPath(target);
1185
+ return isUrl(target) || isAbsolute(target) ? target : resolve(baseDir, target);
1186
+ }
1187
+ async function toDataUrl(source) {
1188
+ if (isUrl(source)) return source;
1189
+ const mime = mimeTypes[extname(source).toLowerCase()];
1190
+ if (!mime) throw new Error(`Unsupported file type for an image or font: ${source}`);
1191
+ return `data:${mime};base64,${(await readFile(source)).toString("base64")}`;
1192
+ }
1193
+
1194
+ // packages/ffmpeg/src/index.ts
1195
+ import { spawn } from "node:child_process";
1196
+ import { once } from "node:events";
1197
+ import { join as join2 } from "node:path";
1198
+ var x264Presets = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"];
1199
+ var defaultPreset = "veryfast";
1200
+ var ffmpegPath = () => process.env.OVE_FFMPEG_PATH || "ffmpeg";
1201
+ var ffprobePath = () => process.env.OVE_FFPROBE_PATH || "ffprobe";
1202
+ var notFound = (tool, env) => new Error(`${tool} was not found. Install FFmpeg (it includes ${tool}) and put it on PATH, or set ${env} to the executable.`);
1203
+ var num2 = (n) => {
1204
+ const text = n.toFixed(5).replace(/\.?0+$/, "");
1205
+ return text === "" || text === "-0" ? "0" : text;
1206
+ };
1207
+ function envelopeExpression(points) {
1208
+ if (points.length === 1) return num2(points[0][1]);
1209
+ const segment = (i) => {
1210
+ const [t0, v0] = points[i], [t1, v1] = points[i + 1];
1211
+ return t1 <= t0 ? num2(v1) : `${num2(v0)}+(${num2(v1 - v0)})*(t-${num2(t0)})/${num2(t1 - t0)}`;
1212
+ };
1213
+ const tree = (lo, hi) => {
1214
+ if (lo === hi) return `(${segment(lo)})`;
1215
+ const mid = Math.floor((lo + hi + 1) / 2);
1216
+ return `if(lt(t,${num2(points[mid][0])}),${tree(lo, mid - 1)},${tree(mid, hi)})`;
1217
+ };
1218
+ const [lastT, lastV] = points[points.length - 1];
1219
+ return `if(gte(t,${num2(lastT)}),${num2(lastV)},${tree(0, points.length - 2)})`;
1220
+ }
1221
+ var seconds = (n) => n.toFixed(6);
1222
+ var tempo = (rate) => {
1223
+ const filters = [];
1224
+ let remaining = rate;
1225
+ while (remaining < 0.5) {
1226
+ filters.push("atempo=0.5");
1227
+ remaining /= 0.5;
1228
+ }
1229
+ if (remaining !== 1) filters.push(`atempo=${remaining}`);
1230
+ return filters;
1231
+ };
1232
+ function audioMixArgs(mix, audioCodec = ["-c:a", "aac", "-b:a", "192k"]) {
1233
+ if (!mix || mix.clips.length === 0) return [];
1234
+ const inputs = [], chains = [];
1235
+ mix.clips.forEach((clip, i) => {
1236
+ inputs.push(...clip.loop ? ["-stream_loop", "-1"] : [], "-i", clip.source);
1237
+ const fadeIn = Math.min(clip.fadeIn ?? 0, clip.duration), fadeOut = Math.min(clip.fadeOut ?? 0, clip.duration);
1238
+ const filters = [`atrim=start=${seconds(clip.trim ?? 0)}`, "asetpts=PTS-STARTPTS", ...tempo(clip.rate ?? 1), `atrim=duration=${seconds(clip.duration)}`, "asetpts=PTS-STARTPTS", "aformat=sample_rates=48000:channel_layouts=stereo", clip.envelope?.length ? `volume='${envelopeExpression(clip.envelope)}':eval=frame` : `volume=${clip.volume ?? 1}`, ...fadeIn > 0 ? [`afade=t=in:d=${seconds(fadeIn)}`] : [], ...fadeOut > 0 ? [`afade=t=out:st=${seconds(clip.duration - fadeOut)}:d=${seconds(fadeOut)}`] : [], `adelay=delays=${Math.round(clip.start * 48e3)}S:all=1`];
1239
+ chains.push(`[${i + 1}:a]${filters.join(",")}[a${i}]`);
1240
+ });
1241
+ const labels = mix.clips.map((_, i) => `[a${i}]`).join("");
1242
+ const mixed = mix.clips.length === 1 ? `${labels}apad` : `${labels}amix=inputs=${mix.clips.length}:duration=longest:normalize=0,apad`, master = mix.gainDb === void 0 ? "" : `,volume=${mix.gainDb.toFixed(2)}dB,alimiter=limit=0.84:level=false:attack=5:release=50`;
1243
+ return [...inputs, "-filter_complex", `${chains.join(";")};${mixed}${master},atrim=start=${seconds(mix.offsetSeconds ?? 0)}:duration=${seconds(mix.durationSeconds)},asetpts=PTS-STARTPTS[aout]`, "-map", "0:v", "-map", "[aout]", ...audioCodec];
1244
+ }
1245
+ var tone = (expr, seconds3) => ["-f", "lavfi", "-i", `aevalsrc=${expr}:s=48000:d=${seconds3}`];
1246
+ var sfxRecipes = {
1247
+ click: tone("0.8*sin(2*PI*2400*t)*exp(-t*150)+0.35*sin(2*PI*5200*t)*exp(-t*300)", 0.06),
1248
+ tick: tone("0.55*sin(2*PI*3800*t)*exp(-t*260)", 0.04),
1249
+ pop: tone("0.9*sin(2*PI*(1000*t-2200*t*t))*exp(-t*28)", 0.18),
1250
+ thump: tone("0.9*sin(2*PI*(110*t-80*t*t))*exp(-t*9)+0.3*sin(2*PI*55*t)*exp(-t*6)", 0.6),
1251
+ ding: tone("0.5*sin(2*PI*1318*t)*exp(-t*3)+0.25*sin(2*PI*3640*t)*exp(-t*5)+0.12*sin(2*PI*7110*t)*exp(-t*8)", 1.8),
1252
+ riser: tone("(t/1.6)*(t/1.6)*(0.6*sin(2*PI*(180*t+380*t*t))+0.25*sin(2*PI*(360*t+760*t*t)))", 1.6),
1253
+ whoosh: ["-f", "lavfi", "-i", "anoisesrc=d=0.7:c=pink:a=0.9:r=48000:seed=11", "-af", "highpass=f=300,lowpass=f=3500,volume='sin(PI*t/0.7)*sin(PI*t/0.7)*sin(PI*t/0.7)':eval=frame,volume=3"]
1254
+ };
1255
+ var sfxArgs = (name2, output) => {
1256
+ const recipe = sfxRecipes[name2];
1257
+ if (!recipe) throw new Error(`Unknown sound effect "${name2}"`);
1258
+ return ["-v", "error", "-y", ...recipe, "-ac", "2", "-ar", "48000", "-c:a", "pcm_s16le", output];
1259
+ };
1260
+ function formatArgs(fps, options) {
1261
+ const preset = options.preset ?? defaultPreset, rate = ["-r", String(fps)];
1262
+ const x26x = (defaultCrf) => options.bitrate ? ["-b:v", options.bitrate, "-maxrate", options.bitrate, "-bufsize", options.bitrate] : ["-crf", String(options.crf ?? defaultCrf)];
1263
+ switch (options.format ?? "mp4") {
1264
+ case "webm":
1265
+ return { video: ["-c:v", "libvpx-vp9", "-b:v", options.bitrate ?? "0", "-crf", String(options.crf ?? 32), "-row-mt", "1", "-deadline", "good", "-cpu-used", "4", "-pix_fmt", options.transparent ? "yuva420p" : "yuv420p", ...rate], audio: ["-c:a", "libopus", "-b:a", options.audioBitrate ?? "160k"] };
1266
+ case "mov":
1267
+ return { video: ["-c:v", "prores_ks", "-profile:v", options.transparent ? "4" : "3", "-pix_fmt", options.transparent ? "yuva444p10le" : "yuv422p10le", "-vendor", "apl0", ...rate], audio: ["-c:a", "pcm_s16le"] };
1268
+ case "gif":
1269
+ return { video: ["-vf", "split[a][b];[a]palettegen=stats_mode=full[p];[b][p]paletteuse=dither=sierra2_4a", "-loop", "0", ...rate], audio: void 0 };
1270
+ default:
1271
+ return { video: options.codec === "h265" ? ["-c:v", "libx265", "-preset", preset, ...x26x(24), "-pix_fmt", "yuv420p", "-tag:v", "hvc1", "-x265-params", "log-level=error:aq-mode=3", "-movflags", "+faststart", ...rate] : ["-c:v", "libx264", "-preset", preset, ...x26x(20), "-x264-params", "aq-mode=3", "-pix_fmt", "yuv420p", "-movflags", "+faststart", ...rate], audio: ["-c:a", "aac", "-b:a", options.audioBitrate ?? "192k"] };
1272
+ }
1273
+ }
1274
+ function run(args, stdin) {
1275
+ const child = spawn(ffmpegPath(), args, { stdio: [stdin, "ignore", "pipe"] });
1276
+ let error = "";
1277
+ child.stderr.on("data", (d) => error += d);
1278
+ const done = new Promise((resolve4, reject) => {
1279
+ child.on("error", (e) => reject(e.code === "ENOENT" ? notFound("ffmpeg", "OVE_FFMPEG_PATH") : e));
1280
+ child.on("close", (code) => code === 0 ? resolve4() : reject(new Error(`FFmpeg exited ${code}: ${error.slice(-1e3)}`)));
1281
+ });
1282
+ return { child, done };
1283
+ }
1284
+ var encodeArgs = (fps, audio, options) => {
1285
+ const { video, audio: audioCodec } = formatArgs(fps, options);
1286
+ return [...audioCodec ? audioMixArgs(audio, audioCodec) : [], ...video];
1287
+ };
1288
+ function createEncoder(fps, output, audio, options = {}) {
1289
+ const { child, done } = run(["-y", "-f", "image2pipe", "-c:v", "png", "-framerate", String(fps), "-i", "-", ...encodeArgs(fps, audio, options), output], "pipe");
1290
+ const stdin = child.stdin;
1291
+ stdin.on("error", () => {
1292
+ });
1293
+ done.catch(() => {
1294
+ });
1295
+ return { async write(frame) {
1296
+ if (!stdin.write(frame)) await Promise.race([once(stdin, "drain"), done.then(() => {
1297
+ throw new Error("FFmpeg exited before all frames were written");
1298
+ })]);
1299
+ }, finish() {
1300
+ stdin.end();
1301
+ return done;
1302
+ }, abort() {
1303
+ child.kill();
1304
+ } };
1305
+ }
1306
+ function concatSegments(segments, listFile, output, audio, options = {}) {
1307
+ const { audio: audioCodec } = formatArgs(30, { format: options.format ?? "mp4", audioBitrate: options.audioBitrate });
1308
+ const list = segments.map((s) => `file '${s.replace(/\\/g, "/").replace(/'/g, "'\\''")}'`).join("\n");
1309
+ return import("node:fs/promises").then((fs) => fs.writeFile(listFile, list)).then(() => run(["-y", "-f", "concat", "-safe", "0", "-i", listFile, ...audioCodec ? audioMixArgs(audio, audioCodec) : [], ...audio && audioCodec ? [] : ["-map", "0:v"], "-c:v", "copy", ...options.format === "mp4" || options.format === void 0 ? ["-movflags", "+faststart"] : [], output], "ignore").done);
1310
+ }
1311
+ function extractFrames(source, outDir, options) {
1312
+ const { fps, maxWidth, maxHeight, durationSeconds, startSeconds } = options;
1313
+ return run(["-v", "error", "-y", ...startSeconds ? ["-ss", String(startSeconds)] : [], ...durationSeconds === void 0 ? [] : ["-t", String(durationSeconds)], "-i", source, "-an", "-vf", `fps=${fps},scale=w='min(iw,${maxWidth})':h='min(ih,${maxHeight})':force_original_aspect_ratio=decrease`, "-q:v", "2", join2(outDir, "%06d.jpg")], "ignore").done;
1314
+ }
1315
+ var requiredEncoders = { mp4: ["libx264", "aac"], "mp4 (h265)": ["libx265"], webm: ["libvpx-vp9", "libopus"], mov: ["prores_ks", "pcm_s16le"], gif: ["gif"], "video layers": ["mjpeg"] };
1316
+ function checkFfmpeg() {
1317
+ return new Promise((resolve4, reject) => {
1318
+ const child = spawn(ffmpegPath(), ["-hide_banner", "-encoders"]);
1319
+ let out = "";
1320
+ child.stdout.on("data", (d) => out += d);
1321
+ child.on("error", (e) => reject(e.code === "ENOENT" ? notFound("ffmpeg", "OVE_FFMPEG_PATH") : e));
1322
+ child.on("close", () => {
1323
+ const have = new Set([...out.matchAll(/^\s*[VAS][.A-Z]{5}\s+(\S+)/gm)].map((m) => m[1]));
1324
+ const missing = Object.fromEntries(Object.entries(requiredEncoders).map(([use, names]) => [use, names.filter((n) => !have.has(n))]).filter(([, names]) => names.length));
1325
+ const version = spawn(ffmpegPath(), ["-version"]);
1326
+ let v = "";
1327
+ version.stdout.on("data", (d) => v += d);
1328
+ version.on("close", () => resolve4({ version: v.split("\n")[0]?.trim() ?? "unknown", missing }));
1329
+ version.on("error", reject);
1330
+ });
1331
+ });
1332
+ }
1333
+ function hasAudioStream(path) {
1334
+ return new Promise((resolve4, reject) => {
1335
+ const child = spawn(ffprobePath(), ["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", path]);
1336
+ let out = "";
1337
+ child.stdout.on("data", (d) => out += d);
1338
+ child.on("error", (e) => reject(e.code === "ENOENT" ? notFound("ffprobe", "OVE_FFPROBE_PATH") : e));
1339
+ child.on("close", (c) => c === 0 ? resolve4(out.trim().length > 0) : reject(new Error(`ffprobe could not read ${path}`)));
1340
+ });
1341
+ }
1342
+ function probe(path) {
1343
+ return new Promise((resolve4, reject) => {
1344
+ const child = spawn(ffprobePath(), ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path]);
1345
+ let out = "";
1346
+ child.stdout.on("data", (d) => out += d);
1347
+ child.on("error", (e) => reject(e.code === "ENOENT" ? notFound("ffprobe", "OVE_FFPROBE_PATH") : e));
1348
+ child.on("close", (c) => c === 0 ? resolve4(out.trim()) : reject(new Error("ffprobe failed")));
1349
+ });
1350
+ }
1351
+
1352
+ // packages/core/src/media.ts
1353
+ var run2 = (cmd, args) => new Promise((ok, fail) => {
1354
+ const child = spawn2(cmd, args);
1355
+ let out = "";
1356
+ child.stdout.on("data", (d) => out += d);
1357
+ child.stderr.on("data", (d) => out += d);
1358
+ child.on("error", fail);
1359
+ child.on("close", (code) => code === 0 ? ok(out) : fail(new Error(`${cmd} failed on ${args[args.indexOf("-i") + 1] ?? args.at(-1)}`)));
1360
+ });
1361
+ var mediaCache = () => cacheDir("media");
1362
+ async function measureSource(source) {
1363
+ const version = isUrl(source) ? "" : await stat2(source).then((s) => `${s.size}:${s.mtimeMs}`, () => {
1364
+ throw new Error(`Media not found: ${source}`);
1365
+ });
1366
+ const key = createHash("sha256").update(`${source}|${version}|v1`).digest("hex").slice(0, 32), file = join3(mediaCache(), `${key}.json`);
1367
+ const cached2 = await readFile2(file, "utf8").then(JSON.parse, () => void 0);
1368
+ if (cached2) {
1369
+ void touchCacheEntry(file);
1370
+ return cached2;
1371
+ }
1372
+ const duration = Number((await run2(ffprobePath(), ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", source])).trim()) || 0;
1373
+ const hasAudio = (await run2(ffprobePath(), ["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", source])).trim().length > 0;
1374
+ let sounds = [];
1375
+ if (hasAudio) {
1376
+ const log = await run2(ffmpegPath(), ["-hide_banner", "-nostats", "-i", source, "-vn", "-af", "silencedetect=noise=-45dB:d=0.25", "-f", "null", "-"]);
1377
+ let soundStart = 0, silent = false;
1378
+ for (const m of log.matchAll(/silence_(start|end): ([\d.]+)/g)) {
1379
+ const t = Number(m[2]);
1380
+ if (m[1] === "start") {
1381
+ sounds.push([soundStart, t]);
1382
+ silent = true;
1383
+ } else {
1384
+ soundStart = t;
1385
+ silent = false;
1386
+ }
1387
+ }
1388
+ if (!silent) sounds.push([soundStart, duration]);
1389
+ sounds = sounds.filter(([a, b]) => b - a > 0.05);
1390
+ }
1391
+ const measured = { duration, sounds };
1392
+ await mkdir(mediaCache(), { recursive: true });
1393
+ await writeFile(file, JSON.stringify(measured));
1394
+ return measured;
1395
+ }
1396
+ async function measureMedia(raw, baseDir, fps = typeof raw?.fps === "number" ? raw.fps : 30) {
1397
+ if (!/\bmedia\s*[.[]/.test(JSON.stringify(raw ?? {}))) return {};
1398
+ const assets = (Array.isArray(raw?.assets) ? raw.assets : []).filter((a) => (a?.type === "audio" || a?.type === "video") && typeof a.src === "string" && !a.src.includes("{{"));
1399
+ const stub = { assets };
1400
+ return Object.fromEntries(await Promise.all(assets.map(async (asset) => {
1401
+ const m = await measureSource(resolveAssetSource(stub, asset.id, baseDir));
1402
+ const segments = m.sounds.map(([start, end]) => ({ start, end, startFrame: Math.floor(start * fps), endFrame: Math.ceil(end * fps) }));
1403
+ const soundEnd = m.sounds.at(-1)?.[1] ?? 0;
1404
+ return [asset.id, { duration: m.duration, frames: Math.ceil(m.duration * fps), segments, soundEnd, soundEndFrames: Math.ceil(soundEnd * fps) }];
1405
+ })));
1406
+ }
1407
+ async function attachCaptions(composition, baseDir) {
1408
+ const files = /* @__PURE__ */ new Map();
1409
+ const walk = async (layers) => {
1410
+ for (const layer of layers) {
1411
+ if (layer.type === "group") await walk(layer.children ?? []);
1412
+ if (layer.type === "captions" && !layer.cues) {
1413
+ const source = resolveAssetSource(composition, layer.src, baseDir);
1414
+ if (!files.has(source)) files.set(source, (isUrl(source) ? fetch(source).then((r) => {
1415
+ if (!r.ok) throw new Error(`Captions not found: ${source}`);
1416
+ return r.text();
1417
+ }) : readFile2(source, "utf8").catch(() => {
1418
+ throw new Error(`Captions not found: ${source}`);
1419
+ })).then((text) => parseCaptions(text, source, Infinity)));
1420
+ layer.cues = await files.get(source);
1421
+ }
1422
+ }
1423
+ };
1424
+ for (const scene of composition.scenes) await walk(scene.layers);
1425
+ return composition;
1426
+ }
1427
+ function captionTrack(composition) {
1428
+ const fps = composition.fps;
1429
+ return layerWindows(composition, (layer) => layer.type === "captions").flatMap(({ layer, startFrame, endFrame }) => {
1430
+ const shift = startFrame / fps + (layer.offset ?? 0), end = endFrame / fps;
1431
+ return captionLines(layer.dropFillers ? dropFillerWords(layer.cues ?? []) : layer.cues ?? [], layer.maxWords).map((cue) => ({ text: cue.text, start: cue.start + shift, end: Math.min(cue.end + shift, end) })).filter((cue) => cue.end > cue.start);
1432
+ }).sort((a, b) => a.start - b.start);
1433
+ }
1434
+ async function prepareComposition(raw, values = {}, baseDir = process.cwd()) {
1435
+ const composition = loadComposition(raw, values, { media: await measureMedia(raw, baseDir) });
1436
+ return attachCaptions(composition, baseDir);
1437
+ }
1438
+
1439
+ // packages/renderer/src/index.ts
1440
+ import { chromium } from "playwright";
1441
+ import { existsSync as existsSync3 } from "node:fs";
1442
+ import { readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
1443
+ import { availableParallelism } from "node:os";
1444
+
1445
+ // packages/graphics/src/index.ts
1446
+ var resolveOrigin = (value, layer, axis) => {
1447
+ if (value !== "center") return typeof value === "number" ? value : 0;
1448
+ if (layer.type === "circle") return 0;
1449
+ if (layer.type === "text") return axis === "y" ? layer.fontSize * layer.lineHeight / 2 : layer.textAlign === "center" ? 0 : (layer.textAlign === "right" ? -1 : 1) * (layer.maxWidth ?? 0) / 2;
1450
+ return ((axis === "x" ? layer.width : layer.height) ?? 0) / 2;
1451
+ };
1452
+ var finalLayer = (layer) => {
1453
+ const merged = { ...layer, ...layer.evaluated };
1454
+ return { ...merged, originX: resolveOrigin(merged.originX, merged, "x"), originY: resolveOrigin(merged.originY, merged, "y") };
1455
+ };
1456
+ var toDrawOperations = (layers) => layers.filter((layer) => layer.type !== "audio").map(finalLayer).map((layer) => layer.type === "group" ? { kind: "group", layer, children: toDrawOperations(layer.children ?? []) } : { kind: layer.type, layer });
1457
+ var flattenLayers = (layers) => layers.flatMap((l) => l.type === "group" ? [l, ...flattenLayers(l.children ?? [])] : [l]);
1458
+ var flattenOperations = (operations) => operations.flatMap((op) => op.kind === "group" ? [op, ...flattenOperations(op.children ?? [])] : [op]);
1459
+ var htmlReferences = (layer) => {
1460
+ const text = `${layer.html ?? ""}
1461
+ ${layer.css ?? ""}`, refs = [...text.matchAll(/(?<![\w:-])src\s*=\s*(["'])([^"']+)\1/gi)].map((m) => m[2]).concat([...text.matchAll(/url\(\s*(["']?)([^"')]+)\1\s*\)/gi)].map((m) => m[2].trim()));
1462
+ return refs.filter((ref) => !/^(data:|#|blob:)/i.test(ref));
1463
+ };
1464
+ var collectCodeSources = (layers) => [...new Set(flattenLayers(layers).filter((l) => (l.type === "code" || l.type === "react") && typeof l.src === "string").map((l) => l.src))];
1465
+ var hasCodeLayers = (layers) => flattenLayers(layers).some((l) => l.type === "code" || l.type === "react");
1466
+ var hasReactLayers = (layers) => flattenLayers(layers).some((l) => l.type === "react");
1467
+ var collectLottieSources = (layers) => [...new Set(flattenLayers(layers).filter((l) => l.type === "lottie").map((l) => l.src))];
1468
+ var collectImageSources = (layers) => [...new Set(flattenLayers(layers).flatMap((l) => l.type === "image" ? [l.src] : l.type === "html" ? htmlReferences(l) : []))];
1469
+ var genericFamilies = /* @__PURE__ */ new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded", "emoji", "math", "fangsong"]);
1470
+ var builtinFontFaces = [
1471
+ { family: "Inter", file: "inter-latin-opsz-normal.woff2", weight: "100 900", style: "normal", unicodeRange: "U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD" },
1472
+ { family: "Inter", file: "inter-latin-ext-opsz-normal.woff2", weight: "100 900", style: "normal", unicodeRange: "U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF" },
1473
+ { family: "Inter", file: "inter-cyrillic-opsz-normal.woff2", weight: "100 900", style: "normal", unicodeRange: "U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116" },
1474
+ { family: "Inter", file: "inter-cyrillic-ext-opsz-normal.woff2", weight: "100 900", style: "normal", unicodeRange: "U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F" },
1475
+ { family: "Inter", file: "inter-greek-opsz-normal.woff2", weight: "100 900", style: "normal", unicodeRange: "U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF" },
1476
+ { family: "Inter", file: "inter-greek-ext-opsz-normal.woff2", weight: "100 900", style: "normal", unicodeRange: "U+1F00-1FFF" },
1477
+ { family: "Inter", file: "inter-vietnamese-opsz-normal.woff2", weight: "100 900", style: "normal", unicodeRange: "U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB" },
1478
+ { family: "Inter", file: "inter-latin-opsz-italic.woff2", weight: "100 900", style: "italic", unicodeRange: "U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD" },
1479
+ { family: "Inter", file: "inter-latin-ext-opsz-italic.woff2", weight: "100 900", style: "italic", unicodeRange: "U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF" },
1480
+ { family: "Inter", file: "inter-cyrillic-opsz-italic.woff2", weight: "100 900", style: "italic", unicodeRange: "U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116" },
1481
+ { family: "Inter", file: "inter-cyrillic-ext-opsz-italic.woff2", weight: "100 900", style: "italic", unicodeRange: "U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F" },
1482
+ { family: "Inter", file: "inter-greek-opsz-italic.woff2", weight: "100 900", style: "italic", unicodeRange: "U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF" },
1483
+ { family: "Inter", file: "inter-greek-ext-opsz-italic.woff2", weight: "100 900", style: "italic", unicodeRange: "U+1F00-1FFF" },
1484
+ { family: "Inter", file: "inter-vietnamese-opsz-italic.woff2", weight: "100 900", style: "italic", unicodeRange: "U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB" }
1485
+ ];
1486
+ var fontStack = (family) => family.split(",").map((f) => f.trim()).filter(Boolean).map((f) => /^["']/.test(f) || genericFamilies.has(f.toLowerCase()) ? f : `"${f.replace(/"/g, "")}"`).join(", ");
1487
+ var lineCache = /* @__PURE__ */ new WeakMap();
1488
+ var cleaned = /* @__PURE__ */ new WeakMap();
1489
+ var linesFor = (raw, maxWords, fillers2 = false) => {
1490
+ let cues = raw;
1491
+ if (fillers2) {
1492
+ cues = cleaned.get(raw) ?? dropFillerWords(raw);
1493
+ cleaned.set(raw, cues);
1494
+ }
1495
+ let byWords = lineCache.get(cues);
1496
+ if (!byWords) lineCache.set(cues, byWords = /* @__PURE__ */ new Map());
1497
+ let lines = byWords.get(maxWords);
1498
+ if (!lines) byWords.set(maxWords, lines = captionLines(cues, maxWords));
1499
+ return lines;
1500
+ };
1501
+ function captionAt(layer, seconds3) {
1502
+ const lines = linesFor(layer.cues ?? [], layer.maxWords, layer.dropFillers), t = seconds3 - (layer.offset ?? 0), stress = new Set((layer.emphasis ?? []).map(captionKey)), mark = (w) => stress.size && stress.has(captionKey(w.text)) ? { ...w, emphasis: true } : w;
1503
+ let cue = lines.find((c) => t >= c.start && t < c.end), finished = false;
1504
+ if (!cue) {
1505
+ cue = [...lines].reverse().find((c) => c.end <= t && t - c.end < 0.3);
1506
+ finished = !!cue;
1507
+ }
1508
+ if (!cue) return null;
1509
+ if (layer.mode === "lines" || !cue.words?.length) return { words: cue.text.split(/\s+/).filter(Boolean).map((text) => mark({ text, state: 2 })) };
1510
+ const current = finished ? cue.words.length : cue.words.reduce((at, w, i) => w.start <= t ? i : at, -1);
1511
+ return { words: cue.words.map((w, i) => mark({ text: w.text, state: i < current ? 2 : i === current ? 1 : 0 })) };
1512
+ }
1513
+ var prepareOperations = (operations, fps = 30) => {
1514
+ for (const op of flattenOperations(operations)) {
1515
+ if (op.kind !== "text" && op.kind !== "captions") continue;
1516
+ const layer = op.layer;
1517
+ op.fontPrefix = `${layer.fontStyle} ${layer.fontWeight}`;
1518
+ op.fontStack = fontStack(layer.fontFamily);
1519
+ if (layer.spans?.length) op.spanStacks = layer.spans.map((span) => fontStack(span.fontFamily ?? layer.fontFamily));
1520
+ if (op.kind === "captions") op.caption = captionAt(layer, (layer.localFrame - layer.startFrame) / fps);
1521
+ }
1522
+ for (const op of flattenOperations(operations)) {
1523
+ const layer = op.layer, own = layer.localFrame - layer.startFrame;
1524
+ if (op.kind === "lottie") op.mediaTime = (layer.trimStart + own * layer.playbackRate) / fps;
1525
+ if (op.kind !== "html" && op.kind !== "shader" && op.kind !== "code" && op.kind !== "react") continue;
1526
+ op.vars = { ...layer.vars, ...Object.fromEntries(Object.entries(layer).filter(([key]) => key.startsWith("--"))), "--t": Math.round(own / fps * 1e6) / 1e6, "--frame": own, ...op.kind === "code" || op.kind === "react" ? { "--fps": fps } : {} };
1527
+ }
1528
+ return operations;
1529
+ };
1530
+
1531
+ // packages/renderer/src/video.ts
1532
+ import { mkdir as mkdir2, readdir as readdir2, rename, rm as rm2, stat as stat3, writeFile as writeFile2 } from "node:fs/promises";
1533
+ import { join as join4 } from "node:path";
1534
+ import { createHash as createHash2, randomUUID } from "node:crypto";
1535
+ var videoFrameFile = (video, index) => join4(video.dir, `${String(Math.max(0, index - video.first) + 1).padStart(6, "0")}.jpg`);
1536
+ var videoCacheRoot = () => cacheDir("video");
1537
+ var complete = ".complete";
1538
+ async function decode(source, key, first = 0) {
1539
+ const version = isUrl(source) ? "" : await stat3(source).then((s) => `${s.size}:${s.mtimeMs}`, () => {
1540
+ throw new Error(`Video not found: ${source}`);
1541
+ });
1542
+ const dir = join4(videoCacheRoot(), createHash2("sha256").update(JSON.stringify({ source, version, ...key })).digest("hex").slice(0, 32));
1543
+ const count = async () => (await readdir2(dir)).filter((f) => f.endsWith(".jpg")).length;
1544
+ if (await stat3(join4(dir, complete)).then(() => true, () => false)) {
1545
+ void touchCacheEntry(dir);
1546
+ return { dir, count: first + await count(), first };
1547
+ }
1548
+ const scratch = `${dir}.partial-${randomUUID()}`;
1549
+ await mkdir2(scratch, { recursive: true });
1550
+ try {
1551
+ await extractFrames(source, scratch, key);
1552
+ await writeFile2(join4(scratch, complete), "");
1553
+ await rename(scratch, dir).catch(async (error) => {
1554
+ if (!await stat3(join4(dir, complete)).then(() => true, () => false)) throw error;
1555
+ });
1556
+ } finally {
1557
+ await rm2(scratch, { recursive: true, force: true });
1558
+ }
1559
+ const frames = await count();
1560
+ if (frames === 0) throw new Error(`No frames could be decoded from ${source}`);
1561
+ return { dir, count: first + frames, first };
1562
+ }
1563
+ async function prepareVideos(composition, baseDir) {
1564
+ const fps = composition.fps, bySource = /* @__PURE__ */ new Map();
1565
+ for (const { layer, startFrame, endFrame } of layerWindows(composition, (l) => l.type === "video")) {
1566
+ const l = layer, first = Math.max(0, Math.floor(l.trimStart)), last = Math.ceil(l.trimStart + (endFrame - startFrame) * l.playbackRate) + 1, seen = bySource.get(l.src);
1567
+ bySource.set(l.src, seen ? { first: Math.min(seen.first, first), last: Math.max(seen.last, last), loop: seen.loop || l.loop } : { first, last, loop: !!l.loop });
1568
+ }
1569
+ return new Map(await Promise.all([...bySource].map(async ([src, range]) => {
1570
+ const source = resolveAssetSource(composition, src, baseDir), size = { fps, maxWidth: composition.width, maxHeight: composition.height };
1571
+ if (range.loop) return [src, await decode(source, size)];
1572
+ const windowed = range.first > 0 ? await decode(source, { ...size, startSeconds: range.first / fps, durationSeconds: (range.last - range.first) / fps }, range.first).catch(() => void 0) : void 0;
1573
+ return [src, windowed ?? await decode(source, { ...size, durationSeconds: range.last / fps })];
1574
+ })));
1575
+ }
1576
+
1577
+ // packages/renderer/src/pool.ts
1578
+ var defaultFrameTimeoutMs = 12e4;
1579
+ var message = (error) => String(error?.message ?? error).replace(/^page\.evaluate: (Error: )?/, "").split("\n")[0];
1580
+ var isDrawingError = (error) => /^Layer "/.test(message(error));
1581
+ async function createPagePool(open, discard, size, timeoutMs = defaultFrameTimeoutMs) {
1582
+ const idle = await Promise.all(Array.from({ length: size }, open));
1583
+ const waiting = [];
1584
+ let alive = size, closed = false, lastOpenError;
1585
+ const acquire = () => idle.pop() ?? new Promise((resolve4, reject) => {
1586
+ if (alive === 0) reject(new Error(`No browser page is available: ${lastOpenError?.message ?? "all failed"}`));
1587
+ else waiting.push({ resolve: resolve4, reject });
1588
+ });
1589
+ const release = (page) => {
1590
+ if (closed) {
1591
+ void discard(page).catch(() => {
1592
+ });
1593
+ return;
1594
+ }
1595
+ const next = waiting.shift();
1596
+ if (next) next.resolve(page);
1597
+ else idle.push(page);
1598
+ };
1599
+ const replace = (page) => {
1600
+ void discard(page).catch(() => {
1601
+ });
1602
+ if (closed) return;
1603
+ open().then(release, (error) => {
1604
+ lastOpenError = error;
1605
+ alive--;
1606
+ if (alive === 0) for (const w of waiting.splice(0)) w.reject(new Error(`Could not open a new browser page: ${error.message}`));
1607
+ });
1608
+ };
1609
+ const withTimeout = (work) => {
1610
+ let timer;
1611
+ return Promise.race([work, new Promise((_, reject) => {
1612
+ timer = setTimeout(() => reject(new Error(`the browser page did not answer within ${Math.round(timeoutMs / 1e3)} s`)), timeoutMs);
1613
+ })]).finally(() => clearTimeout(timer));
1614
+ };
1615
+ return {
1616
+ async run(label, work) {
1617
+ for (let attempt = 0; ; attempt++) {
1618
+ const page = await Promise.resolve(acquire()).catch((error) => {
1619
+ throw new Error(`${label}: ${error.message}`);
1620
+ });
1621
+ try {
1622
+ const result = await withTimeout(work(page));
1623
+ release(page);
1624
+ return result;
1625
+ } catch (error) {
1626
+ if (isDrawingError(error)) {
1627
+ release(page);
1628
+ throw new Error(`${label}: ${message(error)}`);
1629
+ }
1630
+ replace(page);
1631
+ if (attempt >= 1 || closed) throw new Error(`${label}: ${message(error)}${attempt ? " (failed again on a fresh browser page)" : ""}`);
1632
+ }
1633
+ }
1634
+ },
1635
+ async close() {
1636
+ closed = true;
1637
+ for (const w of waiting.splice(0)) w.reject(new Error("The renderer was closed"));
1638
+ await Promise.all(idle.splice(0).map((page) => discard(page).catch(() => {
1639
+ })));
1640
+ }
1641
+ };
1642
+ }
1643
+
1644
+ // packages/graphics/src/fonts.ts
1645
+ import { existsSync } from "node:fs";
1646
+ import { readFile as readFile3 } from "node:fs/promises";
1647
+ import { createRequire } from "node:module";
1648
+ import { dirname, join as join5 } from "node:path";
1649
+ import { fileURLToPath } from "node:url";
1650
+ function builtinFontDir() {
1651
+ const bundled = fileURLToPath(new URL("./fonts/", import.meta.url));
1652
+ if (existsSync(join5(bundled, builtinFontFaces[0].file))) return bundled;
1653
+ return join5(dirname(createRequire(import.meta.url).resolve("@fontsource-variable/inter/package.json")), "files");
1654
+ }
1655
+ var cached;
1656
+ var builtinFonts = () => cached ??= Promise.all(builtinFontFaces.map(async (face) => ({ ...face, src: `builtin:${face.file}`, url: `data:font/woff2;base64,${(await readFile3(join5(builtinFontDir(), face.file))).toString("base64")}` })));
1657
+
1658
+ // packages/graphics/src/runtime.ts
1659
+ import { existsSync as existsSync2, readFileSync } from "node:fs";
1660
+ import { createRequire as createRequire2 } from "node:module";
1661
+ import { join as join6 } from "node:path";
1662
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1663
+ var runtimeDir = () => {
1664
+ const bundled = fileURLToPath2(new URL("./runtime/", import.meta.url));
1665
+ return existsSync2(join6(bundled, "canvas-runtime.js")) ? bundled : fileURLToPath2(new URL("../runtime/", import.meta.url));
1666
+ };
1667
+ var read = (name2) => readFileSync(join6(runtimeDir(), name2), "utf8");
1668
+ var textLayoutSource = read("text-layout.js");
1669
+ var lottieFile = "lottie_light_canvas.min.js";
1670
+ var lottiePath = () => {
1671
+ const bundled = join6(runtimeDir(), lottieFile);
1672
+ return existsSync2(bundled) ? bundled : createRequire2(import.meta.url).resolve(`lottie-web/build/player/${lottieFile}`);
1673
+ };
1674
+ var lottieSource = readFileSync(lottiePath(), "utf8");
1675
+ var canvasRuntimeSource = `${textLayoutSource}
1676
+ ${read("canvas-runtime.js")}`;
1677
+ var layoutText = new Function(`${textLayoutSource}; return layoutText;`)();
1678
+ var layoutRich = new Function(`${textLayoutSource}; return layoutRich;`)();
1679
+
1680
+ // packages/renderer/src/index.ts
1681
+ var scaledSize = (composition, scale = 1) => ({ width: Math.max(2, Math.round(composition.width * scale / 2) * 2), height: Math.max(2, Math.round(composition.height * scale / 2) * 2) });
1682
+ var runtime = `<!doctype html><canvas id="c"></canvas><script>${lottieSource}</script><svg width="0" height="0" style="position:absolute"><path id="measure"/></svg><script>
1683
+ ${canvasRuntimeSource}
1684
+ const rt=createCanvasRuntime(document.querySelector('#c'),document.querySelector('#measure'));
1685
+ window.OVE={load:rt.load,draw:async(p)=>{await rt.draw(p);return rt.canvas.toDataURL('image/png').slice('data:image/png;base64,'.length)},inspect:async(p)=>{await rt.draw({...p,report:true});return rt.report()}};</script>`;
1686
+ var sheetPage = `<!doctype html><canvas id="s"></canvas><script>window.sheet=async({images,labels,columns,cell})=>{const c=document.querySelector('#s'),rows=Math.ceil(images.length/columns),gap=8,label=26,ctx=c.getContext('2d');c.width=columns*cell.w+(columns+1)*gap;c.height=rows*(cell.h+label)+(rows+1)*gap;ctx.fillStyle='#0b0d14';ctx.fillRect(0,0,c.width,c.height);ctx.font='600 15px system-ui,sans-serif';ctx.textBaseline='middle';
1687
+ for(let i=0;i<images.length;i++){const img=new Image();img.src=images[i];await img.decode();const x=gap+(i%columns)*(cell.w+gap),y=gap+Math.floor(i/columns)*(cell.h+label+gap);ctx.drawImage(img,x,y,cell.w,cell.h);ctx.fillStyle='#c9cfdd';ctx.fillText(labels[i],x+2,y+cell.h+label/2)}return c.toDataURL('image/png').slice(22)}</script>`;
1688
+ var allLayers = (composition) => composition.scenes.flatMap((scene) => scene.layers);
1689
+ var browserArgs = ["--use-angle=swiftshader", "--enable-unsafe-swiftshader", ...(process.env.OVE_CHROMIUM_ARGS ?? "").split(/\s+/).filter(Boolean)];
1690
+ var browserInstallHint = `Install one with "ove browser install" (Playwright's Chromium, about 150 MB), install Google Chrome, or set OVE_CHROMIUM_EXECUTABLE to a Chromium-based browser.`;
1691
+ async function launchBrowser() {
1692
+ const explicit = process.env.OVE_CHROMIUM_EXECUTABLE;
1693
+ if (explicit) {
1694
+ if (!existsSync3(explicit)) throw new Error(`OVE_CHROMIUM_EXECUTABLE points to ${explicit}, which does not exist.`);
1695
+ return chromium.launch({ headless: true, executablePath: explicit, args: browserArgs });
1696
+ }
1697
+ if (existsSync3(chromium.executablePath())) return chromium.launch({ headless: true, args: browserArgs });
1698
+ for (const channel of ["chrome", "msedge"]) {
1699
+ try {
1700
+ return await chromium.launch({ headless: true, channel, args: browserArgs });
1701
+ } catch {
1702
+ }
1703
+ }
1704
+ throw new Error(`No Chromium-based browser was found. ${browserInstallHint}`);
1705
+ }
1706
+ async function describeBrowser() {
1707
+ const browser = await launchBrowser();
1708
+ try {
1709
+ return `${browser.browserType().name()} ${browser.version()}${process.env.OVE_CHROMIUM_EXECUTABLE ? ` (OVE_CHROMIUM_EXECUTABLE=${process.env.OVE_CHROMIUM_EXECUTABLE})` : existsSync3(chromium.executablePath()) ? " (Playwright Chromium)" : " (installed Chrome/Edge)"}`;
1710
+ } finally {
1711
+ await browser.close();
1712
+ }
1713
+ }
1714
+ var defaultConcurrency = () => Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2)));
1715
+ async function createRenderer(composition, options = {}) {
1716
+ const baseDir = options.baseDir ?? process.cwd();
1717
+ if (hasCodeLayers(composition.scenes.flatMap((s) => s.layers)) && !options.allowCode) throw new Error("This composition has code layers, which run JavaScript while rendering. Render it only if you trust it, with --allow-code (or allowCode: true).");
1718
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? 1));
1719
+ const output = scaledSize(composition, options.scale ?? composition.export.scale);
1720
+ const videos = await prepareVideos(composition, baseDir);
1721
+ const sources = Object.fromEntries(await Promise.all(collectImageSources(allLayers(composition)).map(async (src) => [src, await toDataUrl(resolveAssetSource(composition, src, baseDir))])));
1722
+ const fonts = [...await builtinFonts(), ...await Promise.all(composition.assets.filter((a) => a.type === "font").map(async (a) => ({ family: a.family ?? a.id, src: a.src, weight: a.weight ?? 400, style: a.style ?? "normal", url: await toDataUrl(resolveAssetSource(composition, a.src, baseDir)) })))];
1723
+ const reactImages = hasReactLayers(allLayers(composition)) ? Object.fromEntries(await Promise.all(composition.assets.filter((a) => a.type === "image").flatMap((a) => [a.id, a.src]).map(async (key) => [key, await toDataUrl(resolveAssetSource(composition, key, baseDir))]))) : {};
1724
+ const codes = Object.fromEntries(await Promise.all(collectCodeSources(allLayers(composition)).map(async (src) => {
1725
+ const file = resolveAssetSource(composition, src, baseDir);
1726
+ try {
1727
+ return [src, { text: await readFile4(file, "utf8") }];
1728
+ } catch (error) {
1729
+ throw new Error(`Code ${src} could not be read from ${file}: ${error.message}`);
1730
+ }
1731
+ })));
1732
+ const lotties = Object.fromEntries(await Promise.all(collectLottieSources(allLayers(composition)).map(async (src) => {
1733
+ const file = resolveAssetSource(composition, src, baseDir);
1734
+ try {
1735
+ return [src, JSON.parse(await readFile4(file, "utf8"))];
1736
+ } catch (error) {
1737
+ throw new Error(`Lottie ${src} could not be read from ${file}: ${error.message}`);
1738
+ }
1739
+ })));
1740
+ const browser = await launchBrowser();
1741
+ try {
1742
+ const openPage = async () => {
1743
+ const context = await browser.newContext({ viewport: output, deviceScaleFactor: 1 });
1744
+ if (Object.keys(codes).length || hasCodeLayers(composition.scenes.flatMap((s) => s.layers))) await context.route(/^(https?|wss?|ftp):/i, (route) => route.abort());
1745
+ const page = await context.newPage();
1746
+ await page.setContent(runtime, { waitUntil: "load" });
1747
+ await page.evaluate(async ({ sources: sources2, fonts: fonts2, lotties: lotties2, codes: codes2 }) => {
1748
+ await window.OVE.load(sources2, fonts2, lotties2, codes2);
1749
+ }, { sources: { ...sources, ...reactImages }, fonts, lotties, codes });
1750
+ return page;
1751
+ };
1752
+ const pool = await createPagePool(openPage, (page) => page.context().close(), concurrency, options.frameTimeoutMs ?? (Number(process.env.OVE_FRAME_TIMEOUT_MS) || defaultFrameTimeoutMs));
1753
+ const attachVideoFrames = async (operations) => {
1754
+ const videoFrames = {};
1755
+ for (const op of flattenOperations(operations).filter((op2) => op2.kind === "video")) {
1756
+ const layer = op.layer, video = videos.get(layer.src);
1757
+ const index = videoFrameIndex(layer, layer.localFrame - layer.startFrame, video.count);
1758
+ op.frameKey = `${layer.src}#${index}`;
1759
+ videoFrames[op.frameKey] ??= `data:image/jpeg;base64,${(await readFile4(videoFrameFile(video, index))).toString("base64")}`;
1760
+ }
1761
+ return videoFrames;
1762
+ };
1763
+ const blur = options.motionBlur === false ? void 0 : composition.motionBlur;
1764
+ const payload = async (frame, sharp = false) => {
1765
+ const operations = prepareOperations(toDrawOperations(evaluateFrame(composition, frame)), composition.fps);
1766
+ const videoFrames = await attachVideoFrames(operations);
1767
+ let subframes;
1768
+ if (blur && !sharp) {
1769
+ subframes = [];
1770
+ for (let i = 0; i < blur.samples; i++) {
1771
+ const t = Math.min(composition.durationFrames - 1e-6, frame + i / blur.samples * blur.shutter / 360);
1772
+ const ops = i === 0 ? operations : prepareOperations(toDrawOperations(evaluateFrame(composition, t)), composition.fps);
1773
+ if (i > 0) Object.assign(videoFrames, await attachVideoFrames(ops));
1774
+ subframes.push({ operations: ops });
1775
+ }
1776
+ }
1777
+ return { width: output.width, height: output.height, scaleX: output.width / composition.width, scaleY: output.height / composition.height, background: composition.background, transparent: composition.export.transparent, grain: composition.grain, frame, operations, videoFrames, subframes };
1778
+ };
1779
+ const renderFrame = async (frame) => {
1780
+ const p = await payload(frame);
1781
+ const png = await pool.run(`Frame ${frame}`, (page) => page.evaluate((p2) => window.OVE.draw(p2), p));
1782
+ return Buffer.from(png, "base64");
1783
+ };
1784
+ const inspectFrame = async (frame) => {
1785
+ const p = await payload(frame, true);
1786
+ return pool.run(`Frame ${frame}`, (page) => page.evaluate((p2) => window.OVE.inspect(p2), p));
1787
+ };
1788
+ const contactSheet = async (frames, options2 = {}) => {
1789
+ const columns = Math.max(1, Math.min(options2.columns ?? 5, frames.length)), cellWidth = Math.round((options2.width ?? 1800) / columns), cell = { w: cellWidth, h: Math.round(cellWidth * composition.height / composition.width) };
1790
+ const images = await Promise.all(frames.map(async (f) => `data:image/png;base64,${(await renderFrame(f)).toString("base64")}`)), labels = frames.map((f) => `${f} \xB7 ${(f / composition.fps).toFixed(1)} s`);
1791
+ const context = await browser.newContext();
1792
+ try {
1793
+ const page = await context.newPage();
1794
+ await page.setContent(sheetPage);
1795
+ return Buffer.from(await page.evaluate((a) => window.sheet(a), { images, labels, columns, cell }), "base64");
1796
+ } finally {
1797
+ await context.close();
1798
+ }
1799
+ };
1800
+ return { concurrency, renderFrame, inspectFrame, contactSheet, async captureFrame(frame, output2) {
1801
+ await writeFile3(output2, await renderFrame(frame));
1802
+ }, close: async () => {
1803
+ await pool.close();
1804
+ await browser.close();
1805
+ } };
1806
+ } catch (error) {
1807
+ await browser.close();
1808
+ throw error;
1809
+ }
1810
+ }
1811
+ async function* renderFrames(renderer, from, to, options = {}) {
1812
+ const lookahead = Math.max(1, options.lookahead ?? renderer.concurrency * 4);
1813
+ const pending = [];
1814
+ let next = from;
1815
+ const fill = () => {
1816
+ while (next < to && pending.length < lookahead) {
1817
+ const job = renderer.renderFrame(next++);
1818
+ job.catch(() => {
1819
+ });
1820
+ pending.push(job);
1821
+ }
1822
+ };
1823
+ try {
1824
+ fill();
1825
+ while (pending.length) {
1826
+ const png = await pending.shift();
1827
+ fill();
1828
+ yield png;
1829
+ }
1830
+ } finally {
1831
+ await Promise.allSettled(pending);
1832
+ }
1833
+ }
1834
+
1835
+ // packages/core/src/audio.ts
1836
+ import { stat as stat4 } from "node:fs/promises";
1837
+ import { spawn as spawn3 } from "node:child_process";
1838
+ import { mkdtemp, rm as rm3, mkdir as mkdir3, rename as rename2 } from "node:fs/promises";
1839
+ import { existsSync as existsSync4 } from "node:fs";
1840
+ import { createHash as createHash3 } from "node:crypto";
1841
+ import { tmpdir as tmpdir2 } from "node:os";
1842
+ import { join as join7 } from "node:path";
1843
+ var runFfmpeg = (args) => new Promise((resolve4, reject) => {
1844
+ const child = spawn3(ffmpegPath(), args, { stdio: ["ignore", "ignore", "pipe"] });
1845
+ let err = "";
1846
+ child.stderr.on("data", (d) => err += d);
1847
+ child.on("error", reject);
1848
+ child.on("close", (code) => code === 0 ? resolve4(err) : reject(new Error(`FFmpeg exited ${code}: ${err.slice(-500)}`)));
1849
+ });
1850
+ var sfxDir = () => cacheDir("sfx");
1851
+ var making = /* @__PURE__ */ new Map();
1852
+ function sfxFile(name2) {
1853
+ const file = join7(sfxDir(), `${name2}-${createHash3("sha256").update(JSON.stringify(sfxArgs(name2, ""))).digest("hex").slice(0, 12)}.wav`);
1854
+ if (existsSync4(file)) {
1855
+ void touchCacheEntry(file);
1856
+ return Promise.resolve(file);
1857
+ }
1858
+ let job = making.get(file);
1859
+ if (!job) {
1860
+ job = (async () => {
1861
+ await mkdir3(sfxDir(), { recursive: true });
1862
+ const temp = `${file}.${process.pid}.tmp.wav`;
1863
+ await runFfmpeg(sfxArgs(name2, temp));
1864
+ await rename2(temp, file);
1865
+ return file;
1866
+ })();
1867
+ making.set(file, job);
1868
+ job.catch(() => making.delete(file));
1869
+ }
1870
+ return job;
1871
+ }
1872
+ var sourceOf = (composition, src, baseDir) => isBuiltinSound(src) ? sfxFile(builtinSoundName(src)) : Promise.resolve(resolveAssetSource(composition, src, baseDir));
1873
+ var ensureExists = async (source) => {
1874
+ if (!isUrl(source)) await stat4(source).catch(() => {
1875
+ throw new Error(`Audio source not found: ${source}`);
1876
+ });
1877
+ };
1878
+ async function planAudio(composition, baseDir, probe2 = hasAudioStream) {
1879
+ const fps = composition.fps, clips = [], windows = audioWindows(composition);
1880
+ const ducking = !!composition.export.duck || windows.some(({ layer }) => layer.duck);
1881
+ const media = {};
1882
+ if (ducking) await Promise.all([...new Set(windows.map(({ layer }) => layer.src))].map(async (src) => {
1883
+ const m = await measureSource(await sourceOf(composition, src, baseDir));
1884
+ media[src] = { duration: m.duration, segments: m.sounds.map(([start, end]) => ({ start, end })) };
1885
+ }));
1886
+ const others = ducking ? soundIntervals(composition, media) : [];
1887
+ const soundtrack = composition.export.audio === void 0 ? void 0 : await sourceOf(composition, composition.export.audio, baseDir);
1888
+ if (soundtrack) {
1889
+ await ensureExists(soundtrack);
1890
+ const duration = composition.durationFrames / fps;
1891
+ clips.push({ source: soundtrack, start: 0, duration, loop: true, volume: composition.export.audioVolume, envelope: volumeEnvelope({ layer: { volume: composition.export.audioVolume }, fps, start: 0, duration, duck: composition.export.duck, others }) });
1892
+ }
1893
+ const hasAudio = /* @__PURE__ */ new Map();
1894
+ for (const { layer, startFrame, endFrame } of windows) {
1895
+ const source = await sourceOf(composition, layer.src, baseDir);
1896
+ await ensureExists(source);
1897
+ if (layer.type === "video") {
1898
+ if (!hasAudio.has(source)) hasAudio.set(source, probe2(source));
1899
+ if (!await hasAudio.get(source)) continue;
1900
+ }
1901
+ const start = startFrame / fps, duration = (endFrame - startFrame) / fps;
1902
+ clips.push({ source, start, duration, trim: layer.trimStart / fps, rate: layer.playbackRate, loop: layer.loop, volume: layer.volume, envelope: volumeEnvelope({ layer, fps, start, duration, duck: layer.duck, others }), ...layer.type === "audio" ? { fadeIn: layer.fadeInFrames / fps, fadeOut: layer.fadeOutFrames / fps } : {} });
1903
+ }
1904
+ return clips.length ? { clips, durationSeconds: composition.durationFrames / fps } : void 0;
1905
+ }
1906
+ async function measureMix(mix) {
1907
+ if (!mix.clips.length) return void 0;
1908
+ const dir = await mkdtemp(join7(tmpdir2(), "ove-loudness-")), wav = join7(dir, "mix.wav");
1909
+ try {
1910
+ const args = audioMixArgs(mix, ["-c:a", "pcm_s16le"]), video = args.indexOf("0:v");
1911
+ if (video > 0) args.splice(video - 1, 2);
1912
+ await runFfmpeg(["-v", "error", "-y", "-f", "lavfi", "-i", "color=c=black:s=16x16:r=1", ...args, "-t", String(mix.durationSeconds), wav]);
1913
+ const summary = (await runFfmpeg(["-hide_banner", "-nostats", "-i", wav, "-af", "ebur128=peak=true", "-f", "null", "-"])).split("Summary:").at(-1) ?? "";
1914
+ const integrated = Number(/I:\s+(-?[\d.]+|-inf) LUFS/.exec(summary)?.[1]), truePeak = Number(/Peak:\s+(-?[\d.]+|-inf) dBFS/.exec(summary)?.[1]);
1915
+ return Number.isFinite(integrated) ? { integrated, truePeak: Number.isFinite(truePeak) ? truePeak : -Infinity } : void 0;
1916
+ } finally {
1917
+ await rm3(dir, { recursive: true, force: true });
1918
+ }
1919
+ }
1920
+ async function masterAudio(composition, baseDir) {
1921
+ const mix = await planAudio(composition, baseDir), target = composition.export.loudness;
1922
+ if (!mix || target === void 0) return mix;
1923
+ const measured = await measureMix({ ...mix, durationSeconds: composition.durationFrames / composition.fps });
1924
+ return measured ? { ...mix, gainDb: Math.max(-40, Math.min(40, target - measured.integrated)) } : mix;
1925
+ }
1926
+ async function measureLoudness(composition, baseDir) {
1927
+ const mix = await masterAudio(composition, baseDir);
1928
+ return mix ? measureMix({ ...mix, durationSeconds: composition.durationFrames / composition.fps }) : void 0;
1929
+ }
1930
+
1931
+ // packages/core/src/review.ts
1932
+ var area = (a) => Math.max(0, a.x1 - a.x0) * Math.max(0, a.y1 - a.y0);
1933
+ var overlap = (a, b) => Math.max(0, Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0)) * Math.max(0, Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0));
1934
+ var seconds2 = (frame, fps) => `${(frame / fps).toFixed(1)} s`;
1935
+ async function reviewComposition(composition, options = {}) {
1936
+ const { fps, width, height, durationFrames } = composition, baseDir = options.baseDir ?? process.cwd();
1937
+ const every = Math.max(1, Math.round(options.every ?? fps / 2)), frames = [];
1938
+ for (let f = 0; f < durationFrames; f += every) frames.push(f);
1939
+ if (frames.at(-1) !== durationFrames - 1) frames.push(durationFrames - 1);
1940
+ const sheetCount = Math.max(1, Math.min(options.sheetFrames ?? 20, durationFrames)), sheetFrames = Array.from({ length: sheetCount }, (_, i) => Math.min(durationFrames - 1, Math.round((i + 0.5) * durationFrames / sheetCount)));
1941
+ const renderer = await createRenderer(composition, { baseDir, concurrency: options.concurrency ?? defaultConcurrency(), scale: Math.min(1, 1280 / Math.max(width, height)), allowCode: options.allowCode });
1942
+ let reports = [], sheet;
1943
+ try {
1944
+ let done = 0;
1945
+ reports = await Promise.all(frames.map((f) => renderer.inspectFrame(f).then((r) => {
1946
+ options.onProgress?.(++done, frames.length);
1947
+ return r;
1948
+ })));
1949
+ sheet = await renderer.contactSheet(sheetFrames, { columns: options.columns ?? (width >= height ? 4 : 6) });
1950
+ } finally {
1951
+ await renderer.close();
1952
+ }
1953
+ const issues = [], minSide = Math.min(width, height), visible = (t) => t.alpha >= 0.95;
1954
+ const persistent = (code, hold2, test, severity) => {
1955
+ const runs = /* @__PURE__ */ new Map(), reported = /* @__PURE__ */ new Set();
1956
+ reports.forEach((report, i) => {
1957
+ const seen = /* @__PURE__ */ new Set();
1958
+ for (const t of report.texts) {
1959
+ const message2 = test(t, report);
1960
+ if (!message2) continue;
1961
+ const key = `${t.id}|${message2}`;
1962
+ seen.add(key);
1963
+ const run3 = runs.get(key) ?? { start: frames[i], count: 0, message: message2 };
1964
+ run3.count++;
1965
+ runs.set(key, run3);
1966
+ if (run3.count >= hold2 && !reported.has(`${t.id}|${t.text}`)) {
1967
+ reported.add(`${t.id}|${t.text}`);
1968
+ issues.push({ severity, code, message: message2, frame: run3.start, layer: t.id });
1969
+ }
1970
+ }
1971
+ for (const key of runs.keys()) if (!seen.has(key)) runs.delete(key);
1972
+ });
1973
+ };
1974
+ const hold = Math.max(2, Math.ceil(fps / every) + 1), margin = 0.01 * minSide, smallest = minSide * 0.018;
1975
+ persistent("text-cut", 1, (t) => t.truncated ? `"${t.text}" is cut short with "\u2026": give it more room (maxWidth/maxLines) or a minFontSize` : void 0, "error");
1976
+ persistent("off-frame", hold, (t) => visible(t) && (t.x0 < -margin || t.y0 < -margin || t.x1 > width + margin || t.y1 > height + margin) ? `"${t.text}" runs off the frame` : void 0, "error");
1977
+ persistent("small-text", hold, (t) => visible(t) && t.size < smallest ? `"${t.text}" is ${Math.round(t.size)} px, too small to read on a phone (labels ${Math.ceil(smallest)}+ px, body ${Math.round(minSide * 0.024)}+ px)` : void 0, "warning");
1978
+ persistent("overlap", hold, (t, report) => {
1979
+ if (!visible(t)) return;
1980
+ const other = report.texts.find((o) => o !== t && o.id !== t.id && visible(o) && overlap(t, o) > 0.15 * Math.min(area(t), area(o)) && (t.kind === "captions" || o.kind === "captions" || t.id < o.id));
1981
+ return other ? t.kind === "captions" || other.kind === "captions" ? `"${(t.kind === "captions" ? other : t).text}" sits under the captions: keep content out of the captions band` : `"${t.text}" overlaps "${other.text}"` : void 0;
1982
+ }, "error");
1983
+ const crowded = reports.map((r) => r.texts.filter((t) => visible(t) && t.kind === "text").reduce((n, t) => n + t.words, 0) > 40), busy = crowded.findIndex((_, i) => i + hold <= crowded.length && crowded.slice(i, i + hold).every(Boolean));
1984
+ if (busy >= 0) issues.push({ severity: "warning", code: "busy", message: `More than 40 words on screen at ${seconds2(frames[busy], fps)}: split it across beats or cut words`, frame: frames[busy] });
1985
+ const texts = reports.flatMap((r) => r.texts).filter((t) => visible(t) && t.kind === "text"), sizes = [...new Set(texts.map((t) => Math.round(t.baseSize)))].sort((a, b) => b - a), colours = [...new Set(texts.map((t) => t.color.toLowerCase()).filter((c) => c !== "gradient"))];
1986
+ const steps = [];
1987
+ for (const size of sizes) {
1988
+ const step = steps.at(-1);
1989
+ if (step && step[0] / size <= 1.12) step.push(size);
1990
+ else steps.push([size]);
1991
+ }
1992
+ const near = steps.filter((s) => s.length > 1);
1993
+ if (steps.length > 8) issues.push({ severity: "warning", code: "type-scale", message: `${steps.length} steps of font size (${steps.map((s) => s.join("/")).join(", ")}): pick a scale of 5\u20138 sizes and reuse them` });
1994
+ else if (near.length) issues.push({ severity: "warning", code: "type-scale", message: `Nearly equal font sizes (${near.map((s) => s.join("/")).join(", ")}): use one size for each step of the type scale` });
1995
+ if (colours.length > 5) issues.push({ severity: "warning", code: "text-colours", message: `${colours.length} text colours (${colours.join(", ")}): use a heading colour, a body colour and one or two accents` });
1996
+ const change = reports.slice(1).map((r, i) => {
1997
+ const a = reports[i].thumb.pixels, b = r.thumb.pixels;
1998
+ let sum = 0;
1999
+ for (let k = 0; k < a.length; k++) sum += Math.abs(a[k] - b[k]);
2000
+ return sum / a.length;
2001
+ });
2002
+ let still = -1;
2003
+ for (let i = 0; i <= change.length; i++) {
2004
+ const isStill = i < change.length && change[i] < 0.5;
2005
+ if (isStill && still < 0) still = i;
2006
+ if (!isStill && still >= 0) {
2007
+ const from = frames[still], to = frames[i];
2008
+ if (to - from >= (to >= durationFrames - 1 ? 6 : 4) * fps) issues.push({ severity: "warning", code: "frozen", message: `Nothing moves from ${seconds2(from, fps)} to ${seconds2(to, fps)}: add slow drift or a beat, or shorten the hold`, frame: from });
2009
+ still = -1;
2010
+ }
2011
+ }
2012
+ const lengths = composition.scenes.map((s) => s.durationFrames), mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
2013
+ if (lengths.length >= 4 && lengths.every((l) => Math.abs(l - mean) <= 0.1 * mean)) issues.push({ severity: "warning", code: "uniform-scenes", message: `All ${lengths.length} scenes are about ${seconds2(Math.round(mean), fps)} long: vary their length so the video has a rhythm` });
2014
+ const transitions = [...new Set(composition.scenes.flatMap((s) => [s.transitionIn?.type, s.transitionOut?.type]).filter(Boolean))];
2015
+ if (transitions.length >= 3) issues.push({ severity: "info", code: "mixed-transitions", message: `${transitions.length} transition styles (${transitions.join(", ")}): one style through a video usually reads as more deliberate` });
2016
+ const loudness = options.audio === false ? void 0 : await measureLoudness(composition, baseDir);
2017
+ if (loudness) {
2018
+ if (loudness.integrated < -20) issues.push({ severity: "warning", code: "quiet", message: `Sound is quiet (${loudness.integrated.toFixed(1)} LUFS): set export.loudness (-14 for social video, -16 for explainers) or raise volumes` });
2019
+ else if (loudness.integrated > -11) issues.push({ severity: "warning", code: "loud", message: `Sound is loud (${loudness.integrated.toFixed(1)} LUFS): set export.loudness (-14 for social video, -16 for explainers) or lower volumes` });
2020
+ if (loudness.truePeak > -1) issues.push({ severity: "warning", code: "peaks", message: `Sound peaks at ${loudness.truePeak.toFixed(1)} dBTP and may distort after upload: keep peaks under -1 dBTP (lower volume)` });
2021
+ }
2022
+ const stretches = /* @__PURE__ */ new Map();
2023
+ reports.forEach((report, i) => {
2024
+ const here = /* @__PURE__ */ new Set();
2025
+ for (const t of report.texts) {
2026
+ if (!visible(t) || t.kind !== "text" || t.words < 3) continue;
2027
+ const key = `${t.id}|${t.text}`;
2028
+ here.add(key);
2029
+ const s = stretches.get(key) ?? { text: t.text, words: t.words, best: 0, run: 0, first: frames[i], start: frames[i], layer: t.id };
2030
+ if (s.run === 0) s.start = frames[i];
2031
+ s.run++;
2032
+ if (s.run > s.best) {
2033
+ s.best = s.run;
2034
+ s.first = s.start;
2035
+ }
2036
+ stretches.set(key, s);
2037
+ }
2038
+ for (const [key, s] of stretches) if (!here.has(key)) s.run = 0;
2039
+ });
2040
+ for (const s of stretches.values()) {
2041
+ const shown = s.best * every / fps, needs = 0.5 + s.words * 0.25;
2042
+ if (shown < needs && s.first + s.best * every < durationFrames - 1) issues.push({ severity: "warning", code: "read-time", message: `"${s.text}" is readable for about ${shown.toFixed(1)} s; its ${s.words} words need about ${needs.toFixed(1)} s: hold it longer or cut words`, frame: s.first, layer: s.layer });
2043
+ }
2044
+ const clusters = (texts2) => {
2045
+ const groups = [];
2046
+ for (const t of [...texts2].sort((a, b) => a.y0 - b.y0)) {
2047
+ const near2 = groups.find((g) => g.some((o) => t.y0 - o.y1 < 0.5 * Math.min(t.size, o.size) && o.y0 - t.y1 < 0.5 * Math.min(t.size, o.size) && Math.min(t.x1, o.x1) > Math.max(t.x0, o.x0)));
2048
+ if (near2) near2.push(t);
2049
+ else groups.push([t]);
2050
+ }
2051
+ return groups;
2052
+ };
2053
+ const headlines = reports.map((r) => clusters(r.texts.filter((t) => visible(t) && t.kind === "text" && t.size >= minSide * 0.06)).map((g) => ({ text: g.map((t) => t.text).join(" ") }))), crowdedHeads = headlines.findIndex((_, i) => i + hold <= headlines.length && headlines.slice(i, i + hold).every((h) => h.length > 2));
2054
+ if (crowdedHeads >= 0) issues.push({ severity: "warning", code: "headlines", message: `${headlines[crowdedHeads].length} headline-size texts at once at ${seconds2(frames[crowdedHeads], fps)} (${headlines[crowdedHeads].map((t) => `"${t.text}"`).join(", ")}): give the frame one hero and make the rest smaller`, frame: frames[crowdedHeads] });
2055
+ let flashes = 0, fastest = 0, fastestAt = -1;
2056
+ for (const cue of captionTrack(composition)) {
2057
+ const length = cue.end - cue.start, cps = cue.text.length / Math.max(0.01, length);
2058
+ if (length < 0.7) flashes++;
2059
+ if (cps > fastest) {
2060
+ fastest = cps;
2061
+ fastestAt = Math.round(cue.start * fps);
2062
+ }
2063
+ }
2064
+ if (flashes) issues.push({ severity: "warning", code: "caption-flash", message: `${flashes} caption line${flashes === 1 ? " is" : "s are"} on screen for under 0.7 s: raise maxWords so short phrases join their neighbours` });
2065
+ if (fastest > 20) issues.push({ severity: "warning", code: "caption-speed", message: `Captions reach ${fastest.toFixed(0)} characters a second at ${seconds2(fastestAt, fps)} (comfortable reading is up to about 20): lower maxWords or slow the voice`, frame: fastestAt });
2066
+ for (const { layer, endFrame, startFrame } of layerWindows(composition, (l) => l.type === "audio" && !l.loop)) {
2067
+ const l = layer;
2068
+ const measured = await measureSource(await sourceOf(composition, l.src, baseDir)).catch(() => void 0);
2069
+ if (!measured) continue;
2070
+ const from = l.trimStart / fps, until = from + (endFrame - startFrame) / fps * l.playbackRate, cut = measured.sounds.find(([a, b]) => a < until - 0.05 && b > until + 0.15);
2071
+ if (cut && endFrame < durationFrames) issues.push({ severity: "warning", code: "sound-cut", message: `"${l.id}" (${l.src}) is cut off mid-sound at ${seconds2(endFrame, fps)}, ${(cut[1] - until).toFixed(1)} s before its phrase ends: lengthen its scene or end it at a pause`, frame: endFrame, layer: l.id });
2072
+ }
2073
+ const order = { error: 0, warning: 1, info: 2 };
2074
+ issues.sort((a, b) => order[a.severity] - order[b.severity] || (a.frame ?? -1) - (b.frame ?? -1));
2075
+ return { issues, sheet, sheetFrames, sampledFrames: frames.length, loudness };
2076
+ }
2077
+
2078
+ // packages/core/src/distributed.ts
2079
+ import { mkdtemp as mkdtemp2, rm as rm4, writeFile as writeFile4, mkdir as mkdir4 } from "node:fs/promises";
2080
+ import { tmpdir as tmpdir3 } from "node:os";
2081
+ import { dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join8, resolve as resolve2 } from "node:path";
2082
+ import { timingSafeEqual } from "node:crypto";
2083
+ function planChunks(first, last, chunkSize) {
2084
+ if (!Number.isInteger(chunkSize) || chunkSize < 1) throw new Error("Chunk size must be a positive whole number of frames");
2085
+ const jobs = [];
2086
+ for (let start = first, index = 0; start <= last; start += chunkSize, index++) jobs.push({ index, frameRange: [start, Math.min(last, start + chunkSize - 1)] });
2087
+ return jobs;
2088
+ }
2089
+ function absolutizeSources(composition, baseDir) {
2090
+ const abs = (src) => isUrl(src) || isAbsolute2(src) || isBuiltinSound(src) || composition.assets.some((a) => a.id === src) ? src : resolve2(baseDir, src);
2091
+ const fix = (layer) => ({ ...layer, ...typeof layer.src === "string" ? { src: abs(layer.src) } : {}, ...layer.children ? { children: layer.children.map(fix) } : {} });
2092
+ return { ...composition, assets: composition.assets.map((a) => ({ ...a, src: isUrl(a.src) || isAbsolute2(a.src) ? a.src : resolve2(baseDir, a.src) })), scenes: composition.scenes.map((s) => ({ ...s, layers: s.layers.map(fix) })), export: { ...composition.export, audio: composition.export.audio && !composition.assets.some((a) => a.id === composition.export.audio) ? abs(composition.export.audio) : composition.export.audio } };
2093
+ }
2094
+ async function renderDistributed(composition, options) {
2095
+ const baseDir = options.baseDir ?? process.cwd(), output = resolve2(options.output);
2096
+ if (!options.workers.length) throw new Error("No workers given");
2097
+ const extension = extname2(output).toLowerCase(), byExtension2 = { ".mp4": "mp4", ".webm": "webm", ".mov": "mov", ".gif": "gif" };
2098
+ const format = options.format ?? byExtension2[extension] ?? (extension === "" ? "png" : composition.export.format);
2099
+ if (format !== "mp4" && format !== "webm" && format !== "mov") throw new Error(`Distributed rendering writes mp4, webm or mov (segments are joined without re-encoding); ${format} is not supported`);
2100
+ const settings = exportSchema.parse({ ...composition.export, ...Object.fromEntries(Object.entries({ crf: options.crf, bitrate: options.bitrate, audioBitrate: options.audioBitrate, scale: options.scale, loudness: options.loudness }).filter(([, v]) => v !== void 0)), format, codec: options.codec ?? (format === "mp4" ? composition.export.codec : "h264"), transparent: options.transparent ?? composition.export.transparent });
2101
+ const target = absolutizeSources({ ...composition, export: settings, ...options.motionBlur === false ? { motionBlur: void 0 } : {} }, baseDir), [first, last] = options.frameRange ?? [0, composition.durationFrames - 1];
2102
+ if (!Number.isInteger(first) || !Number.isInteger(last) || first < 0 || last < first || last >= composition.durationFrames) throw new Error(`Frame range must be within 0\u2013${composition.durationFrames - 1}`);
2103
+ const chunkSettings = { format, codec: settings.codec, transparent: settings.transparent, preset: options.preset ?? defaultPreset, crf: settings.crf, bitrate: settings.bitrate, scale: settings.scale };
2104
+ const jobs = planChunks(first, last, options.chunkSize ?? Math.max(30, Math.ceil((last - first + 1) / (options.workers.length * 2)))), total = last - first + 1, retries = options.retries ?? 2;
2105
+ const work = await mkdtemp2(join8(tmpdir3(), "ove-chunks-")), segments = new Array(jobs.length), attempts = /* @__PURE__ */ new Map();
2106
+ const abort = new AbortController();
2107
+ const onOuterAbort = () => abort.abort(options.signal?.reason);
2108
+ options.signal?.addEventListener("abort", onOuterAbort);
2109
+ try {
2110
+ const queue = [...jobs];
2111
+ let rendered = 0, fatal;
2112
+ let alive = options.workers.length;
2113
+ await Promise.all(options.workers.map(async (worker) => {
2114
+ let failures = 0;
2115
+ while (!fatal && !abort.signal.aborted && queue.length) {
2116
+ const job = queue.shift();
2117
+ try {
2118
+ const bytes = await worker.renderChunk(target, job, chunkSettings, abort.signal);
2119
+ const file = join8(work, `chunk-${String(job.index).padStart(5, "0")}.${format}`);
2120
+ await writeFile4(file, bytes);
2121
+ segments[job.index] = file;
2122
+ failures = 0;
2123
+ rendered += job.frameRange[1] - job.frameRange[0] + 1;
2124
+ options.onProgress?.({ frame: job.frameRange[1], rendered, total, chunk: job.index, worker: worker.name });
2125
+ } catch (error) {
2126
+ if (abort.signal.aborted) break;
2127
+ options.onWorkerError?.(worker.name, error);
2128
+ const n = (attempts.get(job.index) ?? 0) + 1;
2129
+ attempts.set(job.index, n);
2130
+ if (n > retries) {
2131
+ fatal = new Error(`Chunk ${job.index} (frames ${job.frameRange[0]}\u2013${job.frameRange[1]}) failed ${n} times; last error from ${worker.name}: ${error.message}`);
2132
+ abort.abort(fatal);
2133
+ break;
2134
+ }
2135
+ queue.unshift(job);
2136
+ if (++failures >= 2) {
2137
+ alive--;
2138
+ if (alive === 0) {
2139
+ fatal = new Error(`Every worker failed; last error from ${worker.name}: ${error.message}`);
2140
+ abort.abort(fatal);
2141
+ }
2142
+ break;
2143
+ }
2144
+ }
2145
+ }
2146
+ }));
2147
+ if (fatal) throw fatal;
2148
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("Render cancelled");
2149
+ if (segments.some((s) => !s)) throw new Error("Some chunks were not rendered");
2150
+ await mkdir4(dirname2(output), { recursive: true });
2151
+ const audio = await masterAudio(target, baseDir).then((mix) => mix && { ...mix, durationSeconds: total / composition.fps, offsetSeconds: first / composition.fps });
2152
+ await concatSegments(segments, join8(work, "segments.txt"), output, audio, { format, audioBitrate: settings.audioBitrate });
2153
+ return { output, format, frames: total, durationSeconds: Number(await probe(output)) || total / composition.fps };
2154
+ } finally {
2155
+ options.signal?.removeEventListener("abort", onOuterAbort);
2156
+ await rm4(work, { recursive: true, force: true });
2157
+ }
2158
+ }
2159
+ function httpWorker(url, token) {
2160
+ const base = url.replace(/\/+$/, "");
2161
+ return { name: base, async renderChunk(composition, job, settings, signal) {
2162
+ const response = await fetch(`${base}/render`, { method: "POST", signal, headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ composition, frameRange: job.frameRange, ...settings }) });
2163
+ if (!response.ok) throw new Error(`${response.status} ${(await response.text()).slice(0, 500)}`);
2164
+ return Buffer.from(await response.arrayBuffer());
2165
+ } };
2166
+ }
2167
+ var tokensMatch = (given, expected) => {
2168
+ const a = Buffer.from(given), b = Buffer.from(expected);
2169
+ return a.length === b.length && timingSafeEqual(a, b);
2170
+ };
2171
+
2172
+ // packages/core/src/index.ts
2173
+ async function loadCompositionFile(path, values = {}) {
2174
+ const file = resolve3(path), baseDir = dirname3(file);
2175
+ return { composition: await prepareComposition(JSON.parse(await readFile5(file, "utf8")), values, baseDir), baseDir };
2176
+ }
2177
+ var byExtension = { ".mp4": "mp4", ".webm": "webm", ".mov": "mov", ".gif": "gif" };
2178
+ var formatForOutput = (output, fallback) => byExtension[extname3(output).toLowerCase()] ?? (extname3(output) === "" ? "png" : fallback);
2179
+ function checkRange(composition, range) {
2180
+ const [first, last] = range ?? [0, composition.durationFrames - 1];
2181
+ if (!Number.isInteger(first) || !Number.isInteger(last) || first < 0 || last < first || last >= composition.durationFrames) throw new Error(`Frame range must be whole frames within 0\u2013${composition.durationFrames - 1}, first \u2264 last; got ${first}\u2013${last}`);
2182
+ return [first, last];
2183
+ }
2184
+ async function renderMedia(composition, options) {
2185
+ const baseDir = options.baseDir ?? process.cwd(), output = resolve3(options.output);
2186
+ const format = options.format ?? formatForOutput(output, composition.export.format);
2187
+ const settings = exportSchema.parse({ ...composition.export, ...Object.fromEntries(Object.entries({ crf: options.crf, bitrate: options.bitrate, audioBitrate: options.audioBitrate, scale: options.scale, loudness: options.loudness }).filter(([, v]) => v !== void 0)), format, codec: options.codec ?? (format === "mp4" ? composition.export.codec : "h264"), transparent: options.transparent ?? composition.export.transparent });
2188
+ const target = { ...composition, export: settings };
2189
+ const [first, last] = checkRange(target, options.frameRange), total = last - first + 1, fps = target.fps;
2190
+ const throwIfAborted = () => {
2191
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("Render cancelled");
2192
+ };
2193
+ throwIfAborted();
2194
+ await mkdir5(format === "png" ? output : dirname3(output), { recursive: true });
2195
+ const renderer = await createRenderer(target, { baseDir, concurrency: options.concurrency ?? defaultConcurrency(), scale: settings.scale, motionBlur: options.motionBlur, allowCode: options.allowCode });
2196
+ const audio = format === "png" || format === "gif" || options.audio === false ? void 0 : await masterAudio(target, baseDir).then((mix) => mix && { ...mix, durationSeconds: total / fps, offsetSeconds: first / fps });
2197
+ const encoder = format === "png" ? void 0 : createEncoder(fps, output, audio, { format, codec: settings.codec, preset: options.preset ?? defaultPreset, transparent: settings.transparent, crf: settings.crf, bitrate: settings.bitrate, audioBitrate: settings.audioBitrate });
2198
+ try {
2199
+ let frame = first;
2200
+ for await (const png of renderFrames(renderer, first, last + 1)) {
2201
+ throwIfAborted();
2202
+ if (encoder) await encoder.write(png);
2203
+ else await writeFile5(join9(output, `frame-${String(frame).padStart(6, "0")}.png`), png);
2204
+ frame++;
2205
+ options.onProgress?.({ frame: frame - 1, rendered: frame - first, total });
2206
+ }
2207
+ await encoder?.finish();
2208
+ } catch (error) {
2209
+ encoder?.abort();
2210
+ throw error;
2211
+ } finally {
2212
+ await renderer.close();
2213
+ }
2214
+ return { output, format, frames: total, durationSeconds: format === "png" ? total / fps : Number(await probe(output)) || total / fps };
2215
+ }
2216
+ async function renderStill(composition, options = {}) {
2217
+ const frame = options.frame ?? 0;
2218
+ checkRange(composition, [frame, frame]);
2219
+ const target = options.transparent === void 0 ? composition : { ...composition, export: { ...composition.export, transparent: options.transparent } };
2220
+ const renderer = await createRenderer(target, { baseDir: options.baseDir ?? process.cwd(), allowCode: options.allowCode });
2221
+ try {
2222
+ const png = await renderer.renderFrame(frame);
2223
+ if (options.output) {
2224
+ await mkdir5(dirname3(resolve3(options.output)), { recursive: true });
2225
+ await writeFile5(resolve3(options.output), png);
2226
+ }
2227
+ return png;
2228
+ } finally {
2229
+ await renderer.close();
2230
+ }
2231
+ }
2232
+ export {
2233
+ VariableError,
2234
+ absolutizeSources,
2235
+ assetSchema,
2236
+ attachCaptions,
2237
+ audioWindows,
2238
+ beatFunctions,
2239
+ blendModes,
2240
+ browserInstallHint,
2241
+ builtinSoundName,
2242
+ cacheRoot,
2243
+ cacheUsage,
2244
+ cameraSchema,
2245
+ cameraState,
2246
+ captionCueSchema,
2247
+ captionKey,
2248
+ captionLines,
2249
+ captionTrack,
2250
+ captionWordSchema,
2251
+ checkFfmpeg,
2252
+ cleanCache,
2253
+ coerceVariable,
2254
+ colorPattern,
2255
+ colorProperties,
2256
+ componentSchema,
2257
+ componentsSchema,
2258
+ compositionJsonSchema,
2259
+ compositionSchema,
2260
+ createRenderer,
2261
+ cssVariableName,
2262
+ defaultConcurrency,
2263
+ defaultPreset,
2264
+ describeBrowser,
2265
+ describeError,
2266
+ dropFillerWords,
2267
+ duckSchema,
2268
+ easingSchema,
2269
+ evaluateFrame,
2270
+ expandMattes,
2271
+ expandSceneGrades,
2272
+ exportSchema,
2273
+ ffmpegPath,
2274
+ ffprobePath,
2275
+ formatCaptions,
2276
+ formatForOutput,
2277
+ frameToSeconds,
2278
+ gradeLooks,
2279
+ gradeSchema,
2280
+ gradientSchema,
2281
+ groupCaptionWords,
2282
+ httpWorker,
2283
+ interpolate,
2284
+ isBuiltinSound,
2285
+ launchBrowser,
2286
+ layerSchema,
2287
+ layerWindows,
2288
+ listFieldSchema,
2289
+ loadComposition,
2290
+ loadCompositionFile,
2291
+ masterAudio,
2292
+ measureLoudness,
2293
+ measureMedia,
2294
+ measureMix,
2295
+ outputFormats,
2296
+ parseCaptions,
2297
+ parseComposition,
2298
+ planAudio,
2299
+ planChunks,
2300
+ prepareComposition,
2301
+ renderDistributed,
2302
+ renderFrames,
2303
+ renderMedia,
2304
+ renderStill,
2305
+ requiredEncoders,
2306
+ resolveVariables,
2307
+ reviewComposition,
2308
+ sceneSchema,
2309
+ secondsToFrame,
2310
+ sequenceScenes,
2311
+ sfxFile,
2312
+ sfxNames,
2313
+ soundIntervals,
2314
+ spanSchema,
2315
+ splitCount,
2316
+ splitPresetAnimations,
2317
+ splitPresets,
2318
+ splitSchema,
2319
+ substituteVariables,
2320
+ tempoSchema,
2321
+ tokensMatch,
2322
+ transitionSchema,
2323
+ transitionState,
2324
+ unitProperties,
2325
+ variableSchema,
2326
+ variablesSchema,
2327
+ videoFrameIndex,
2328
+ volumeEnvelope,
2329
+ x264Presets
2330
+ };