@lalalic/markcut 1.0.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 (98) hide show
  1. package/.env.example +27 -0
  2. package/.github/user-steer.md +9 -0
  3. package/.vscode/settings.json +3 -0
  4. package/AGENTS.md +271 -0
  5. package/README.md +219 -0
  6. package/SKILL.md +209 -0
  7. package/docs/dynamic-components.md +191 -0
  8. package/docs/edit-mode.md +220 -0
  9. package/docs/json-descriptive.md +539 -0
  10. package/docs/label-mode.md +110 -0
  11. package/docs/markdown-descriptive.md +751 -0
  12. package/docs/templates.md +52 -0
  13. package/package.json +64 -0
  14. package/remotion.config.ts +5 -0
  15. package/scripts/artlist-dl.mjs +190 -0
  16. package/scripts/build-pipeline.sh +19 -0
  17. package/scripts/build-player.sh +20 -0
  18. package/src/Root.tsx +55 -0
  19. package/src/config.mjs +88 -0
  20. package/src/context/EventContext.tsx +168 -0
  21. package/src/context/index.tsx +42 -0
  22. package/src/descriptive/compiler.test.ts +1135 -0
  23. package/src/descriptive/compiler.ts +1230 -0
  24. package/src/descriptive/dsl.ts +455 -0
  25. package/src/descriptive/markdown.test.ts +866 -0
  26. package/src/descriptive/markdown.ts +674 -0
  27. package/src/descriptive/resolve.test.ts +951 -0
  28. package/src/descriptive/resolve.ts +891 -0
  29. package/src/entry.tsx +163 -0
  30. package/src/index.ts +4 -0
  31. package/src/player/browser.tsx +356 -0
  32. package/src/player/bundle/player.js +60259 -0
  33. package/src/player/bundler.mjs +269 -0
  34. package/src/player/label-server.mjs +599 -0
  35. package/src/player/pipeline.mjs +11123 -0
  36. package/src/player/pipeline.ts +117 -0
  37. package/src/player/server-shared.mjs +144 -0
  38. package/src/player/server.mjs +1006 -0
  39. package/src/render/cli-tools.ts +177 -0
  40. package/src/render/cli.mjs +628 -0
  41. package/src/schema/index.ts +259 -0
  42. package/src/types/Audio.tsx +56 -0
  43. package/src/types/Component.tsx +135 -0
  44. package/src/types/Effect.tsx +88 -0
  45. package/src/types/Folder.tsx +180 -0
  46. package/src/types/FrameSyncStyle.tsx +51 -0
  47. package/src/types/Image.tsx +51 -0
  48. package/src/types/Include.tsx +394 -0
  49. package/src/types/Map.tsx +252 -0
  50. package/src/types/Rhythm.tsx +58 -0
  51. package/src/types/Scene.tsx +42 -0
  52. package/src/types/Subtitle.tsx +218 -0
  53. package/src/types/Video.tsx +70 -0
  54. package/src/types/keyframes.ts +454 -0
  55. package/src/utils/__tests__/vtt.test.ts +129 -0
  56. package/src/utils/index.ts +168 -0
  57. package/src/utils/tween.ts +118 -0
  58. package/src/vision/cli.mjs +1187 -0
  59. package/src/vision/vision_prompts.md +67 -0
  60. package/tests/dsl.test.ts +317 -0
  61. package/tests/fixtures/audio.json +25 -0
  62. package/tests/fixtures/basic.json +27 -0
  63. package/tests/fixtures/component-all.json +38 -0
  64. package/tests/fixtures/components.json +38 -0
  65. package/tests/fixtures/effects.json +64 -0
  66. package/tests/fixtures/full.json +51 -0
  67. package/tests/fixtures/map.json +23 -0
  68. package/tests/fixtures/md/all-nodes.md +28 -0
  69. package/tests/fixtures/md/basic.md +6 -0
  70. package/tests/fixtures/md/component-imports.md +20 -0
  71. package/tests/fixtures/md/edge-cases.md +33 -0
  72. package/tests/fixtures/md/effects.md +17 -0
  73. package/tests/fixtures/md/frontmatter.md +20 -0
  74. package/tests/fixtures/md/full-feature.md +58 -0
  75. package/tests/fixtures/md/imports-block.md +19 -0
  76. package/tests/fixtures/md/include-main.md +11 -0
  77. package/tests/fixtures/md/include-sub.md +25 -0
  78. package/tests/fixtures/md/jsx-code-fence.md +21 -0
  79. package/tests/fixtures/md/map.md +11 -0
  80. package/tests/fixtures/md/nested-scenes.md +25 -0
  81. package/tests/fixtures/md/rhythm.md +17 -0
  82. package/tests/fixtures/md/scenes.md +16 -0
  83. package/tests/fixtures/md/tween.md +11 -0
  84. package/tests/fixtures/md/vars-test.md +6 -0
  85. package/tests/fixtures/scenes.json +40 -0
  86. package/tests/fixtures/subtitle.json +59 -0
  87. package/tests/fixtures/subvideo.json +59 -0
  88. package/tests/fixtures/templates/courseware.md +351 -0
  89. package/tests/fixtures/tween-visual.json +28 -0
  90. package/tests/fixtures/video-series.json +54 -0
  91. package/tests/md-descriptive.test.ts +742 -0
  92. package/tests/render.test.ts +985 -0
  93. package/tests/schema.test.ts +68 -0
  94. package/tests/server.test.ts +308 -0
  95. package/tests/utils.ts +391 -0
  96. package/tests/vitest.config.ts +18 -0
  97. package/tests/vitest.integration.config.ts +16 -0
  98. package/tsconfig.json +20 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Frame-accurate CSS keyframe interpolation using Remotion's interpolate().
3
+ * No browser DOM deps.
4
+ *
5
+ * Keyframes are defined as JSON: { "0": { opacity: "0" }, "100": { opacity: "1" } }
6
+ * Bundled presets cover common animate.css animations.
7
+ */
8
+ import { interpolate, Easing } from "remotion";
9
+
10
+ /** Percentage → style properties. Keys are stringified percentages (0–100). */
11
+ export type KeyframeData = Record<string, Record<string, string>>;
12
+
13
+ export interface KeyframeConfig {
14
+ steps: number[]; // sorted descending (100, 50, 0, …)
15
+ styles: Record<string, Record<string, string>>;
16
+ timingFunction?: string;
17
+ }
18
+
19
+ export function parseKeyframeData(data: KeyframeData): KeyframeConfig {
20
+ const styles: Record<string, Record<string, string>> = {};
21
+ const steps: number[] = [];
22
+ for (const [key, style] of Object.entries(data)) {
23
+ const perc = parseFloat(key);
24
+ styles[String(perc)] = style;
25
+ if (!steps.includes(perc)) steps.push(perc);
26
+ }
27
+ steps.sort((a, b) => b - a);
28
+ return { steps, styles };
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Easing helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function getEasing(timing?: string): ((t: number) => number) | undefined {
36
+ if (!timing) return undefined;
37
+ const parts = timing.split(/[(),]/);
38
+ const fx = (parts[0] ?? "").trim();
39
+ switch (fx) {
40
+ case "linear":
41
+ return Easing.linear;
42
+ case "cubic-bezier":
43
+ return Easing.bezier(
44
+ parseFloat(parts[1] ?? "0"),
45
+ parseFloat(parts[2] ?? "0"),
46
+ parseFloat(parts[3] ?? "0"),
47
+ parseFloat(parts[4] ?? "0"),
48
+ );
49
+ case "ease":
50
+ return Easing.ease;
51
+ case "ease-in":
52
+ return Easing.in(Easing.ease);
53
+ case "ease-out":
54
+ return Easing.out(Easing.ease);
55
+ case "ease-in-out":
56
+ return Easing.inOut(Easing.ease);
57
+ default:
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Value interpolation — replaces numbers inside CSS value strings
64
+ // ---------------------------------------------------------------------------
65
+
66
+ function interpolateValue(
67
+ from: string,
68
+ to: string,
69
+ fn: (range: [number, number]) => number,
70
+ ): string {
71
+ const numRe = /(-?[\d.]+)/g;
72
+ const toNums = to.match(numRe);
73
+ const fromNums = from.match(numRe);
74
+ if (!fromNums && !toNums) return to;
75
+ if (!fromNums || !toNums || fromNums.length !== toNums.length) return to;
76
+ let i = 0;
77
+ return from.replace(numRe, () => {
78
+ const result = fn([parseFloat(fromNums[i] ?? "0"), parseFloat(toNums[i] ?? "0")]);
79
+ i++;
80
+ return String(Math.round(result * 1000) / 1000);
81
+ });
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Main interpolation
86
+ // ---------------------------------------------------------------------------
87
+
88
+ export function interpolateKeyframes(
89
+ config: KeyframeConfig,
90
+ frame: number,
91
+ opts: { fps: number; durationInSeconds: number; timingFunction?: string },
92
+ ): Record<string, string> | null {
93
+ const { fps, durationInSeconds, timingFunction } = opts;
94
+ const durationInFrames = durationInSeconds * fps;
95
+ if (durationInFrames <= 0) return null;
96
+
97
+ const perc = (frame * 100) / durationInFrames;
98
+ const { steps, styles } = config;
99
+
100
+ // Find the two keyframe stops bracketing the current percentage
101
+ const i = steps.findIndex((s) => s <= perc);
102
+ if (i === -1) return null;
103
+
104
+ const fromPerc = steps[i]!;
105
+ const toPerc = steps[i - 1] ?? fromPerc;
106
+ const fromStyle = styles[String(fromPerc)];
107
+ const toStyle = styles[String(toPerc)] ?? fromStyle;
108
+ if (!fromStyle || !toStyle) return null;
109
+
110
+ const frameRange: [number, number] = [
111
+ Math.floor((fromPerc * durationInFrames) / 100),
112
+ Math.floor((toPerc * durationInFrames) / 100),
113
+ ];
114
+
115
+ const easing = getEasing(timingFunction || config.timingFunction);
116
+
117
+ const result: Record<string, string> = {};
118
+ for (const key of Object.keys(toStyle)) {
119
+ if (key === "animationTimingFunction") continue;
120
+ const fromVal = fromStyle[key] ?? toStyle[key];
121
+ const toVal = toStyle[key] ?? fromVal;
122
+ if (!fromVal || !toVal) continue;
123
+ result[key] = interpolateValue(fromVal, toVal, (valueRange) => {
124
+ if (valueRange[0] === valueRange[1] || frameRange[0] === frameRange[1]) {
125
+ return valueRange[0];
126
+ }
127
+ return interpolate(frame, frameRange, valueRange, {
128
+ easing,
129
+ extrapolateLeft: "clamp",
130
+ extrapolateRight: "clamp",
131
+ });
132
+ });
133
+ }
134
+
135
+ return Object.keys(result).length > 0 ? result : null;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Resolver
140
+ // ---------------------------------------------------------------------------
141
+
142
+ export function resolveAnimation(
143
+ name: string,
144
+ custom?: KeyframeData,
145
+ ): KeyframeConfig | null {
146
+ if (custom) return parseKeyframeData(custom);
147
+ return builtinAnimations[name] ?? null;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Bundled animations (animate.css subset, all properties explicit per step)
152
+ // ---------------------------------------------------------------------------
153
+
154
+ export const builtinAnimations: Record<string, KeyframeConfig> = {
155
+ // ── Fades ──────────────────────────────────────────────────────────────
156
+ fadeIn: parseKeyframeData({
157
+ "0": { opacity: "0" },
158
+ "100": { opacity: "1" },
159
+ }),
160
+ fadeOut: parseKeyframeData({
161
+ "0": { opacity: "1" },
162
+ "100": { opacity: "0" },
163
+ }),
164
+ fadeInDown: parseKeyframeData({
165
+ "0": { opacity: "0", transform: "translate3d(0, -100%, 0)" },
166
+ "100": { opacity: "1", transform: "translate3d(0, 0, 0)" },
167
+ }),
168
+ fadeInUp: parseKeyframeData({
169
+ "0": { opacity: "0", transform: "translate3d(0, 100%, 0)" },
170
+ "100": { opacity: "1", transform: "translate3d(0, 0, 0)" },
171
+ }),
172
+ fadeInLeft: parseKeyframeData({
173
+ "0": { opacity: "0", transform: "translate3d(-100%, 0, 0)" },
174
+ "100": { opacity: "1", transform: "translate3d(0, 0, 0)" },
175
+ }),
176
+ fadeInRight: parseKeyframeData({
177
+ "0": { opacity: "0", transform: "translate3d(100%, 0, 0)" },
178
+ "100": { opacity: "1", transform: "translate3d(0, 0, 0)" },
179
+ }),
180
+ fadeOutDown: parseKeyframeData({
181
+ "0": { opacity: "1", transform: "translate3d(0, 0, 0)" },
182
+ "100": { opacity: "0", transform: "translate3d(0, 100%, 0)" },
183
+ }),
184
+ fadeOutUp: parseKeyframeData({
185
+ "0": { opacity: "1", transform: "translate3d(0, 0, 0)" },
186
+ "100": { opacity: "0", transform: "translate3d(0, -100%, 0)" },
187
+ }),
188
+ fadeOutLeft: parseKeyframeData({
189
+ "0": { opacity: "1", transform: "translate3d(0, 0, 0)" },
190
+ "100": { opacity: "0", transform: "translate3d(-100%, 0, 0)" },
191
+ }),
192
+ fadeOutRight: parseKeyframeData({
193
+ "0": { opacity: "1", transform: "translate3d(0, 0, 0)" },
194
+ "100": { opacity: "0", transform: "translate3d(100%, 0, 0)" },
195
+ }),
196
+
197
+ // ── Slides ─────────────────────────────────────────────────────────────
198
+ slideInDown: parseKeyframeData({
199
+ "0": { transform: "translate3d(0, -100%, 0)" },
200
+ "100": { transform: "translate3d(0, 0, 0)" },
201
+ }),
202
+ slideInUp: parseKeyframeData({
203
+ "0": { transform: "translate3d(0, 100%, 0)" },
204
+ "100": { transform: "translate3d(0, 0, 0)" },
205
+ }),
206
+ slideInLeft: parseKeyframeData({
207
+ "0": { transform: "translate3d(-100%, 0, 0)" },
208
+ "100": { transform: "translate3d(0, 0, 0)" },
209
+ }),
210
+ slideInRight: parseKeyframeData({
211
+ "0": { transform: "translate3d(100%, 0, 0)" },
212
+ "100": { transform: "translate3d(0, 0, 0)" },
213
+ }),
214
+
215
+ // ── Zooms ──────────────────────────────────────────────────────────────
216
+ zoomIn: parseKeyframeData({
217
+ "0": { opacity: "0", transform: "scale3d(0.3, 0.3, 0.3)" },
218
+ "50": { opacity: "1", transform: "scale3d(0.65, 0.65, 0.65)" },
219
+ "100": { opacity: "1", transform: "scale3d(1, 1, 1)" },
220
+ }),
221
+ zoomOut: parseKeyframeData({
222
+ "0": { opacity: "1", transform: "scale3d(1, 1, 1)" },
223
+ "50": { opacity: "0", transform: "scale3d(0.3, 0.3, 0.3)" },
224
+ "100": { opacity: "0", transform: "scale3d(0.3, 0.3, 0.3)" },
225
+ }),
226
+
227
+ // ── Attention seekers ──────────────────────────────────────────────────
228
+ pulse: parseKeyframeData({
229
+ "0": { transform: "scale3d(1, 1, 1)" },
230
+ "50": { transform: "scale3d(1.05, 1.05, 1.05)" },
231
+ "100": { transform: "scale3d(1, 1, 1)" },
232
+ }),
233
+ flash: parseKeyframeData({
234
+ "0": { opacity: "1" },
235
+ "25": { opacity: "0" },
236
+ "50": { opacity: "1" },
237
+ "75": { opacity: "0" },
238
+ "100": { opacity: "1" },
239
+ }),
240
+ bounce: parseKeyframeData({
241
+ "0": { transform: "translate3d(0, 0, 0)" },
242
+ "20": { transform: "translate3d(0, 0, 0)" },
243
+ "40": { transform: "translate3d(0, -30px, 0) scaleY(1.1)" },
244
+ "43": { transform: "translate3d(0, -30px, 0) scaleY(1.1)" },
245
+ "53": { transform: "translate3d(0, 0, 0)" },
246
+ "70": { transform: "translate3d(0, -15px, 0) scaleY(1.05)" },
247
+ "80": { transform: "translate3d(0, 0, 0) scaleY(0.95)" },
248
+ "90": { transform: "translate3d(0, -4px, 0) scaleY(1.02)" },
249
+ "100": { transform: "translate3d(0, 0, 0)" },
250
+ }),
251
+ heartBeat: parseKeyframeData({
252
+ "0": { transform: "scale(1)" },
253
+ "14": { transform: "scale(1.3)" },
254
+ "28": { transform: "scale(1)" },
255
+ "42": { transform: "scale(1.3)" },
256
+ "70": { transform: "scale(1)" },
257
+ "100": { transform: "scale(1)" },
258
+ }),
259
+ rubberBand: parseKeyframeData({
260
+ "0": { transform: "scale3d(1, 1, 1)" },
261
+ "30": { transform: "scale3d(1.25, 0.75, 1)" },
262
+ "40": { transform: "scale3d(0.75, 1.25, 1)" },
263
+ "50": { transform: "scale3d(1.15, 0.85, 1)" },
264
+ "65": { transform: "scale3d(0.95, 1.05, 1)" },
265
+ "75": { transform: "scale3d(1.05, 0.95, 1)" },
266
+ "100": { transform: "scale3d(1, 1, 1)" },
267
+ }),
268
+ shakeX: parseKeyframeData({
269
+ "0": { transform: "translate3d(0, 0, 0)" },
270
+ "10": { transform: "translate3d(-10px, 0, 0)" },
271
+ "20": { transform: "translate3d(10px, 0, 0)" },
272
+ "30": { transform: "translate3d(-10px, 0, 0)" },
273
+ "40": { transform: "translate3d(10px, 0, 0)" },
274
+ "50": { transform: "translate3d(-10px, 0, 0)" },
275
+ "60": { transform: "translate3d(10px, 0, 0)" },
276
+ "70": { transform: "translate3d(-10px, 0, 0)" },
277
+ "80": { transform: "translate3d(10px, 0, 0)" },
278
+ "90": { transform: "translate3d(-10px, 0, 0)" },
279
+ "100": { transform: "translate3d(0, 0, 0)" },
280
+ }),
281
+
282
+ // ── Bouncing entrances ─────────────────────────────────────────────────
283
+ bounceIn: parseKeyframeData({
284
+ "0": { opacity: "0", transform: "scale3d(0.3, 0.3, 0.3)" },
285
+ "20": { opacity: "0.3", transform: "scale3d(1.1, 1.1, 1.1)" },
286
+ "40": { opacity: "0.6", transform: "scale3d(0.9, 0.9, 0.9)" },
287
+ "60": { opacity: "1", transform: "scale3d(1.03, 1.03, 1.03)" },
288
+ "80": { opacity: "1", transform: "scale3d(0.97, 0.97, 0.97)" },
289
+ "100": { opacity: "1", transform: "scale3d(1, 1, 1)" },
290
+ }),
291
+
292
+ // ── Rotations ──────────────────────────────────────────────────────────
293
+ rotateIn: parseKeyframeData({
294
+ "0": { opacity: "0", transform: "rotate(-200deg)" },
295
+ "100": { opacity: "1", transform: "rotate(0deg)" },
296
+ }),
297
+ rotateOut: parseKeyframeData({
298
+ "0": { opacity: "1", transform: "rotate(0deg)" },
299
+ "100": { opacity: "0", transform: "rotate(200deg)" },
300
+ }),
301
+ rotateInDownLeft: parseKeyframeData({
302
+ "0": { opacity: "0", transform: "rotate3d(0, 0, 1, -45deg)" },
303
+ "100": { opacity: "1", transform: "rotate3d(0, 0, 1, 0deg)" },
304
+ }),
305
+ rotateInDownRight: parseKeyframeData({
306
+ "0": { opacity: "0", transform: "rotate3d(0, 0, 1, 45deg)" },
307
+ "100": { opacity: "1", transform: "rotate3d(0, 0, 1, 0deg)" },
308
+ }),
309
+ rotateInUpLeft: parseKeyframeData({
310
+ "0": { opacity: "0", transform: "rotate3d(0, 0, 1, 45deg)" },
311
+ "100": { opacity: "1", transform: "rotate3d(0, 0, 1, 0deg)" },
312
+ }),
313
+ rotateInUpRight: parseKeyframeData({
314
+ "0": { opacity: "0", transform: "rotate3d(0, 0, 1, -45deg)" },
315
+ "100": { opacity: "1", transform: "rotate3d(0, 0, 1, 0deg)" },
316
+ }),
317
+
318
+ // ── Flips ──────────────────────────────────────────────────────────────
319
+ flipInX: parseKeyframeData({
320
+ "0": { opacity: "0", transform: "perspective(400px) rotate3d(1, 0, 0, 90deg)" },
321
+ "40": { opacity: "1", transform: "perspective(400px) rotate3d(1, 0, 0, -20deg)" },
322
+ "60": { transform: "perspective(400px) rotate3d(1, 0, 0, 10deg)" },
323
+ "80": { transform: "perspective(400px) rotate3d(1, 0, 0, -5deg)" },
324
+ "100": { opacity: "1", transform: "perspective(400px)" },
325
+ }),
326
+ flipInY: parseKeyframeData({
327
+ "0": { opacity: "0", transform: "perspective(400px) rotate3d(0, 1, 0, 90deg)" },
328
+ "40": { opacity: "1", transform: "perspective(400px) rotate3d(0, 1, 0, -20deg)" },
329
+ "60": { transform: "perspective(400px) rotate3d(0, 1, 0, 10deg)" },
330
+ "80": { transform: "perspective(400px) rotate3d(0, 1, 0, -5deg)" },
331
+ "100": { opacity: "1", transform: "perspective(400px)" },
332
+ }),
333
+
334
+ // ── Slides out ─────────────────────────────────────────────────────────
335
+ slideOutDown: parseKeyframeData({
336
+ "0": { transform: "translate3d(0, 0, 0)" },
337
+ "100": { transform: "translate3d(0, 100%, 0)" },
338
+ }),
339
+ slideOutLeft: parseKeyframeData({
340
+ "0": { transform: "translate3d(0, 0, 0)" },
341
+ "100": { transform: "translate3d(-100%, 0, 0)" },
342
+ }),
343
+ slideOutRight: parseKeyframeData({
344
+ "0": { transform: "translate3d(0, 0, 0)" },
345
+ "100": { transform: "translate3d(100%, 0, 0)" },
346
+ }),
347
+ slideOutUp: parseKeyframeData({
348
+ "0": { transform: "translate3d(0, 0, 0)" },
349
+ "100": { transform: "translate3d(0, -100%, 0)" },
350
+ }),
351
+
352
+ // ── Zoom variants ──────────────────────────────────────────────────────
353
+ zoomInDown: parseKeyframeData({
354
+ "0": { opacity: "0", transform: "scale3d(0.1, 0.1, 0.1) translate3d(0, -1000px, 0)" },
355
+ "60": { opacity: "1", transform: "scale3d(0.475, 0.475, 0.475) translate3d(0, 60px, 0)" },
356
+ "100": { opacity: "1", transform: "scale3d(1, 1, 1) translate3d(0, 0, 0)" },
357
+ }),
358
+ zoomInLeft: parseKeyframeData({
359
+ "0": { opacity: "0", transform: "scale3d(0.1, 0.1, 0.1) translate3d(-1000px, 0, 0)" },
360
+ "60": { opacity: "1", transform: "scale3d(0.475, 0.475, 0.475) translate3d(10px, 0, 0)" },
361
+ "100": { opacity: "1", transform: "scale3d(1, 1, 1) translate3d(0, 0, 0)" },
362
+ }),
363
+ zoomInRight: parseKeyframeData({
364
+ "0": { opacity: "0", transform: "scale3d(0.1, 0.1, 0.1) translate3d(1000px, 0, 0)" },
365
+ "60": { opacity: "1", transform: "scale3d(0.475, 0.475, 0.475) translate3d(-10px, 0, 0)" },
366
+ "100": { opacity: "1", transform: "scale3d(1, 1, 1) translate3d(0, 0, 0)" },
367
+ }),
368
+ zoomInUp: parseKeyframeData({
369
+ "0": { opacity: "0", transform: "scale3d(0.1, 0.1, 0.1) translate3d(0, 1000px, 0)" },
370
+ "60": { opacity: "1", transform: "scale3d(0.475, 0.475, 0.475) translate3d(0, -60px, 0)" },
371
+ "100": { opacity: "1", transform: "scale3d(1, 1, 1) translate3d(0, 0, 0)" },
372
+ }),
373
+
374
+ // ── Attention seekers (extended) ───────────────────────────────────────
375
+ shakeY: parseKeyframeData({
376
+ "0": { transform: "translate3d(0, 0, 0)" },
377
+ "10": { transform: "translate3d(0, -10px, 0)" },
378
+ "20": { transform: "translate3d(0, 10px, 0)" },
379
+ "30": { transform: "translate3d(0, -10px, 0)" },
380
+ "40": { transform: "translate3d(0, 10px, 0)" },
381
+ "50": { transform: "translate3d(0, -10px, 0)" },
382
+ "60": { transform: "translate3d(0, 10px, 0)" },
383
+ "70": { transform: "translate3d(0, -10px, 0)" },
384
+ "80": { transform: "translate3d(0, 10px, 0)" },
385
+ "90": { transform: "translate3d(0, -10px, 0)" },
386
+ "100": { transform: "translate3d(0, 0, 0)" },
387
+ }),
388
+ swing: parseKeyframeData({
389
+ "20": { transform: "rotate3d(0, 0, 1, 15deg)" },
390
+ "40": { transform: "rotate3d(0, 0, 1, -10deg)" },
391
+ "60": { transform: "rotate3d(0, 0, 1, 5deg)" },
392
+ "80": { transform: "rotate3d(0, 0, 1, -5deg)" },
393
+ "100": { transform: "rotate3d(0, 0, 1, 0deg)" },
394
+ }),
395
+ tada: parseKeyframeData({
396
+ "0": { transform: "scale3d(1, 1, 1)" },
397
+ "10": { transform: "scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg)" },
398
+ "20": { transform: "scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg)" },
399
+ "30": { transform: "scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg)" },
400
+ "50": { transform: "scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg)" },
401
+ "70": { transform: "scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg)" },
402
+ "90": { transform: "scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg)" },
403
+ "100": { transform: "scale3d(1, 1, 1)" },
404
+ }),
405
+ wobble: parseKeyframeData({
406
+ "0": { transform: "translate3d(0, 0, 0)" },
407
+ "15": { transform: "translate3d(-25%, 0, 0) rotate3d(0, 0, 1, -5deg)" },
408
+ "30": { transform: "translate3d(20%, 0, 0) rotate3d(0, 0, 1, 3deg)" },
409
+ "45": { transform: "translate3d(-15%, 0, 0) rotate3d(0, 0, 1, -3deg)" },
410
+ "60": { transform: "translate3d(10%, 0, 0) rotate3d(0, 0, 1, 2deg)" },
411
+ "75": { transform: "translate3d(-5%, 0, 0) rotate3d(0, 0, 1, -1deg)" },
412
+ "100": { transform: "translate3d(0, 0, 0)" },
413
+ }),
414
+ jello: parseKeyframeData({
415
+ "0": { transform: "translate3d(0, 0, 0)" },
416
+ "11.1": { transform: "translate3d(0, 0, 0)" },
417
+ "22.2": { transform: "skewX(-12.5deg) skewY(-12.5deg)" },
418
+ "33.3": { transform: "skewX(6.25deg) skewY(6.25deg)" },
419
+ "44.4": { transform: "skewX(-3.125deg) skewY(-3.125deg)" },
420
+ "55.5": { transform: "skewX(1.5625deg) skewY(1.5625deg)" },
421
+ "66.6": { transform: "skewX(-0.78125deg) skewY(-0.78125deg)" },
422
+ "77.7": { transform: "skewX(0.390625deg) skewY(0.390625deg)" },
423
+ "88.8": { transform: "skewX(-0.1953125deg) skewY(-0.1953125deg)" },
424
+ "100": { transform: "translate3d(0, 0, 0)" },
425
+ }),
426
+
427
+ // ── Specials ───────────────────────────────────────────────────────────
428
+ rollIn: parseKeyframeData({
429
+ "0": { opacity: "0", transform: "translate3d(-100%, 0, 0) rotate3d(0, 0, 1, -120deg)" },
430
+ "100": { opacity: "1", transform: "translate3d(0, 0, 0)" },
431
+ }),
432
+ rollOut: parseKeyframeData({
433
+ "0": { opacity: "1", transform: "translate3d(0, 0, 0)" },
434
+ "100": { opacity: "0", transform: "translate3d(100%, 0, 0) rotate3d(0, 0, 1, 120deg)" },
435
+ }),
436
+ jackInTheBox: parseKeyframeData({
437
+ "0": { opacity: "0", transform: "scale(0.1) rotate(30deg)" },
438
+ "50": { transform: "rotate(-10deg)" },
439
+ "70": { transform: "rotate(3deg)" },
440
+ "100": { opacity: "1", transform: "scale(1)" },
441
+ }),
442
+
443
+ // ── Light speed ────────────────────────────────────────────────────────
444
+ lightSpeedIn: parseKeyframeData({
445
+ "0": { opacity: "0", transform: "translate3d(100%, 0, 0) skewX(-30deg)" },
446
+ "60": { opacity: "1", transform: "skewX(20deg)" },
447
+ "80": { transform: "skewX(-5deg)" },
448
+ "100": { opacity: "1", transform: "translate3d(0, 0, 0)" },
449
+ }),
450
+ lightSpeedOut: parseKeyframeData({
451
+ "0": { opacity: "1", transform: "translate3d(0, 0, 0) skewX(0)" },
452
+ "100": { opacity: "0", transform: "translate3d(100%, 0, 0) skewX(30deg)" },
453
+ }),
454
+ };
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseVTT, vttSecond, VTT_REG } from "../index";
3
+
4
+ describe("vttSecond", () => {
5
+ it("parses MM:SS.mmm format", () => {
6
+ expect(vttSecond("01:30.500")).toBeCloseTo(90.5);
7
+ });
8
+
9
+ it("parses HH:MM:SS.mmm format", () => {
10
+ expect(vttSecond("01:30:15.250")).toBeCloseTo(5415.25);
11
+ });
12
+
13
+ it("parses MM:SS without milliseconds", () => {
14
+ expect(vttSecond("02:15")).toBe(135);
15
+ });
16
+
17
+ it("parses HH:MM:SS without milliseconds", () => {
18
+ expect(vttSecond("01:00:00")).toBe(3600);
19
+ });
20
+
21
+ it("returns 0 for invalid input", () => {
22
+ expect(vttSecond("not-a-time")).toBe(0);
23
+ expect(vttSecond("")).toBe(0);
24
+ });
25
+ });
26
+
27
+ describe("VTT_REG regex", () => {
28
+ it("matches MM:SS.mmm", () => {
29
+ const m = VTT_REG.exec("01:30.500");
30
+ expect(m).not.toBeNull();
31
+ expect(m![1]).toBeUndefined(); // no hours
32
+ expect(m![2]).toBe("01");
33
+ expect(m![3]).toBe("30");
34
+ expect(m![4]).toBe("500");
35
+ });
36
+
37
+ it("matches HH:MM:SS.mmm", () => {
38
+ const m = VTT_REG.exec("01:30:15.250");
39
+ expect(m![1]).toBe("01");
40
+ expect(m![2]).toBe("30");
41
+ expect(m![3]).toBe("15");
42
+ expect(m![4]).toBe("250");
43
+ });
44
+ });
45
+
46
+ describe("parseVTT", () => {
47
+ it("parses a single cue", () => {
48
+ const vtt = `WEBVTT\n\n00:00:01.000 --> 00:00:03.500\nHello world`;
49
+ const cues = parseVTT(vtt);
50
+ expect(cues).toHaveLength(1);
51
+ expect(cues[0]!.startFrom).toBeCloseTo(1);
52
+ expect(cues[0]!.endAt).toBeCloseTo(3.5);
53
+ expect(cues[0]!.text).toBe("Hello world");
54
+ });
55
+
56
+ it("parses multiple cues", () => {
57
+ const vtt = `WEBVTT\n\n1\n00:00:01.000 --> 00:00:02.000\nFirst\n\n2\n00:00:03.000 --> 00:00:04.500\nSecond cue`;
58
+ const cues = parseVTT(vtt);
59
+ expect(cues).toHaveLength(2);
60
+ expect(cues[0]!.text).toBe("First");
61
+ expect(cues[1]!.text).toBe("Second cue");
62
+ expect(cues[1]!.startFrom).toBeCloseTo(3);
63
+ expect(cues[1]!.endAt).toBeCloseTo(4.5);
64
+ });
65
+
66
+ it("parses multi-line text", () => {
67
+ const vtt = `WEBVTT\n\n00:00:00.500 --> 00:00:02.000\nLine one\nLine two`;
68
+ const cues = parseVTT(vtt);
69
+ expect(cues).toHaveLength(1);
70
+ expect(cues[0]!.text).toBe("Line one\nLine two");
71
+ });
72
+
73
+ it("handles Windows-style line endings", () => {
74
+ const vtt = "WEBVTT\r\n\r\n00:00:01.000 --> 00:00:02.000\r\nHello";
75
+ const cues = parseVTT(vtt);
76
+ expect(cues).toHaveLength(1);
77
+ expect(cues[0]!.text).toBe("Hello");
78
+ });
79
+
80
+ it("returns empty array for VTT with no cues", () => {
81
+ const cues = parseVTT("WEBVTT\n\n");
82
+ expect(cues).toHaveLength(0);
83
+ });
84
+
85
+ it("parses cues without HH: prefix", () => {
86
+ const vtt = `WEBVTT\n\n01:30.500 --> 02:00.000\nNo hours`;
87
+ const cues = parseVTT(vtt);
88
+ expect(cues).toHaveLength(1);
89
+ expect(cues[0]!.startFrom).toBeCloseTo(90.5);
90
+ expect(cues[0]!.endAt).toBeCloseTo(120);
91
+ });
92
+ });
93
+
94
+ describe("VTT merge simulation", () => {
95
+ it("shifts cue timestamps by an offset", () => {
96
+ // Simulates what resolveSubtitles does: take per-clip VTTs
97
+ // and merge them with absolute offsets
98
+ const clipVtt = `WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nHello`;
99
+ const cues = parseVTT(clipVtt);
100
+ const offset = 10;
101
+
102
+ const shifted = cues.map((c) => ({
103
+ start: c.startFrom + offset,
104
+ end: c.endAt + offset,
105
+ text: c.text,
106
+ }));
107
+
108
+ expect(shifted).toHaveLength(1);
109
+ expect(shifted[0]!.start).toBe(10);
110
+ expect(shifted[0]!.end).toBe(12);
111
+ expect(shifted[0]!.text).toBe("Hello");
112
+ });
113
+
114
+ it("merges cues from multiple clips", () => {
115
+ const clip1 = `WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nClip one`;
116
+ const clip2 = `WEBVTT\n\n00:00:00.000 --> 00:00:01.500\nClip two`;
117
+
118
+ const allCues = [
119
+ ...parseVTT(clip1).map((c) => ({ ...c, start: c.startFrom + 0, end: c.endAt + 0 })),
120
+ ...parseVTT(clip2).map((c) => ({ ...c, start: c.startFrom + 5, end: c.endAt + 5 })),
121
+ ];
122
+
123
+ expect(allCues).toHaveLength(2);
124
+ expect(allCues[0]!.start).toBe(0);
125
+ expect(allCues[0]!.end).toBe(1);
126
+ expect(allCues[1]!.start).toBe(5);
127
+ expect(allCues[1]!.end).toBe(6.5);
128
+ });
129
+ });