@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,455 @@
1
+ /**
2
+ * Line-level DSL parsing primitives for the descriptive markdown format.
3
+ *
4
+ * This module is intentionally free of markdown-structure concerns (headings,
5
+ * bullet lists, indentation, frontmatter). It deals only with turning a single
6
+ * line of text into typed key/value attributes:
7
+ *
8
+ * "video src:a.mp4 duration:2 effects:[fadeIn]" → { src, duration, effects }
9
+ *
10
+ * The markdown structure layer (`markdown.ts`) uses remark for the hard part
11
+ * (nesting, code fences, frontmatter) and delegates each line here.
12
+ *
13
+ * Error handling: all thrown errors are {@link DslError} instances carrying
14
+ * optional source position so the markdown layer can pinpoint failures.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ import type { DescriptiveMapWaypoint } from "./compiler";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Validation constant sets
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export const LAYOUT_VALUES = new Set([
26
+ "series",
27
+ "parallel",
28
+ "transitionSeries",
29
+ "transition",
30
+ ] as const);
31
+
32
+ export const TRANSITION_VALUES = new Set([
33
+ "fade",
34
+ "slide",
35
+ "wipe",
36
+ "flip",
37
+ "clockWipe",
38
+ ] as const);
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Error handling
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Optional source-location context threaded through the DSL parsers. */
45
+ export interface ParseContext {
46
+ /** 1-based source line number, for error messages. */
47
+ line?: number;
48
+ /** Raw source text of the line being parsed, for error display. */
49
+ lineText?: string;
50
+ }
51
+
52
+ /**
53
+ * Error thrown by the DSL layer. Carries optional source context so callers
54
+ * (the markdown structure layer) can report precise locations to users.
55
+ *
56
+ * Example rendered message:
57
+ * invalid layout value: sideays at line 8
58
+ * | layout:sideays
59
+ */
60
+ export class DslError extends Error {
61
+ constructor(
62
+ message: string,
63
+ public readonly context?: ParseContext & { token?: string },
64
+ ) {
65
+ const parts: string[] = [message];
66
+ if (context?.line) parts.push(`at line ${context.line}`);
67
+ if (context?.token) parts.push(`near "${context.token}"`);
68
+ const head = parts.join(" ");
69
+ const src = context?.lineText ? `\n | ${context.lineText}` : "";
70
+ super(src ? `${head}\n${src}` : head);
71
+ this.name = "DslError";
72
+ }
73
+ }
74
+
75
+ function fail(message: string, ctx?: ParseContext & { token?: string }): never {
76
+ throw new DslError(message, ctx);
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // String helpers
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /** True when a token is wrapped in double quotes (e.g. `"hello world"`). */
84
+ export function isQuoted(token: string): boolean {
85
+ return token.length >= 2 && token.startsWith('"') && token.endsWith('"');
86
+ }
87
+
88
+ /** Strip surrounding double quotes from a token, if present. */
89
+ export function unquote(token: string): string {
90
+ return isQuoted(token) ? token.slice(1, -1) : token;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Tokenizer
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Split a line into whitespace-delimited tokens while respecting:
99
+ * - double-quoted spans: `a "b c" d` → ["a", "\"b c\"", "d"]
100
+ * - bracket nesting: `subtitle:{a:1 b:2}` → one token
101
+ * - paren nesting: `on:(start, x.y=1)` → one token
102
+ *
103
+ * Quotes and brackets inside the respective delimiters are kept intact.
104
+ */
105
+ export function splitTokens(line: string): string[] {
106
+ const out: string[] = [];
107
+ let cur = "";
108
+ let quote = false;
109
+ let depth = 0;
110
+
111
+ for (let i = 0; i < line.length; i++) {
112
+ const ch = line[i]!;
113
+ if (ch === '"') {
114
+ cur += ch;
115
+ quote = !quote;
116
+ continue;
117
+ }
118
+ if (!quote && (ch === "{" || ch === "[" || ch === "(")) {
119
+ depth++;
120
+ cur += ch;
121
+ continue;
122
+ }
123
+ if (!quote && (ch === "}" || ch === "]" || ch === ")")) {
124
+ depth = Math.max(0, depth - 1);
125
+ cur += ch;
126
+ continue;
127
+ }
128
+ if (!quote && depth === 0 && /\s/.test(ch)) {
129
+ if (cur) {
130
+ out.push(cur);
131
+ cur = "";
132
+ }
133
+ continue;
134
+ }
135
+ cur += ch;
136
+ }
137
+ if (cur) out.push(cur);
138
+ return out;
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Scalar coercion
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * Coerce a raw string into a number/boolean when it looks like one, otherwise
147
+ * return the string unchanged. Used for unquoted scalar values like
148
+ * `duration:2`, `loop:true`, `volume:0.8`.
149
+ */
150
+ export function parseNumberMaybe(v: string): number | string | boolean {
151
+ if (v === "true") return true;
152
+ if (v === "false") return false;
153
+ const n = Number(v);
154
+ return Number.isFinite(n) ? n : v;
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Structured value parsers (return empty/undefined on malformed input)
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /**
162
+ * Parse a map `waypoints:[...]` value into an array of waypoints.
163
+ *
164
+ * Format: `[lat,lng,"Label"; lat,lng,"Label"]` — semicolon-separated entries,
165
+ * comma-separated fields. Returns `[]` on any structural mismatch.
166
+ */
167
+ export function parseWaypoints(raw: string): DescriptiveMapWaypoint[] {
168
+ const s = raw.trim();
169
+ if (!s.startsWith("[") || !s.endsWith("]")) return [];
170
+ const body = s.slice(1, -1).trim();
171
+ if (!body) return [];
172
+ return body.split(";").map((part) => {
173
+ const bits = splitTokens(part.replace(/,/g, " "));
174
+ const lat = Number(bits[0] ?? 0);
175
+ const lng = Number(bits[1] ?? 0);
176
+ const labelRaw = bits[2];
177
+ const label = labelRaw ? unquote(labelRaw) : undefined;
178
+ return { lat, lng, label };
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Parse a JSON-like props/imports string into an object or array.
184
+ *
185
+ * Accepts standard JSON, then falls back to a lenient two-pass normalization
186
+ * that quotes bare keys (`{foo:` → `{"foo":`) and bare string values, and
187
+ * finally to `eval` for JSX-like expressions. Returns `{}` on total failure.
188
+ */
189
+ export function parseProps(raw: string): unknown {
190
+ const s = raw.trim();
191
+ if (!s.startsWith("{") && !s.startsWith("[")) return {};
192
+ if (!s.endsWith("}") && !s.endsWith("]")) return {};
193
+ try {
194
+ return JSON.parse(s);
195
+ } catch {
196
+ // Lenient parse: add quotes around unquoted keys and string values.
197
+ // First pass: quote bare keys: {foo: → {"foo":
198
+ let normalized = s.replace(
199
+ /([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:(?=\s*["{[]?)/g,
200
+ '$1"$2":',
201
+ );
202
+ // Second pass: quote bare string values that aren't numbers, booleans, or null
203
+ // Matches :value followed by , } ] or end
204
+ normalized = normalized.replace(
205
+ /:\s*([a-zA-Z_$][a-zA-Z0-9_$.+-]*)\s*(?=[,}\]\s]|$)/g,
206
+ (_match, value: string) => {
207
+ if (value === "true" || value === "false" || value === "null") return `: ${value}`;
208
+ if (/^[+-]?\d+(\.\d+)?$/.test(value)) return `: ${value}`;
209
+ return `: "${value}"`;
210
+ },
211
+ );
212
+ try {
213
+ return JSON.parse(normalized);
214
+ } catch {
215
+ // Last resort: eval (safe since this is a CLI tool)
216
+ try {
217
+ const result = (0, eval)("(" + s + ")");
218
+ return typeof result === "object" && result !== null ? result : {};
219
+ } catch {
220
+ return {};
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Parse an `on:(when, code)` event spec.
228
+ *
229
+ * Positional arguments: first is the when-trigger, second is the state code.
230
+ * Example: `on:(start, slide1.current=1)` → `{ when: "start", state: "slide1.current=1" }`.
231
+ * Returns `undefined` on structural mismatch.
232
+ */
233
+ export function parseOnSpec(
234
+ raw: string,
235
+ ): { when: string; state: string } | undefined {
236
+ const s = raw.trim();
237
+ if (!s.startsWith("(") || !s.endsWith(")")) return undefined;
238
+ const body = s.slice(1, -1).trim();
239
+ if (!body) return undefined;
240
+
241
+ const parts: string[] = [];
242
+ let current = "";
243
+ let depth = 0;
244
+ let inQuote = false;
245
+
246
+ for (let i = 0; i < body.length; i++) {
247
+ const ch = body[i]!;
248
+ if (ch === '"') {
249
+ inQuote = !inQuote;
250
+ current += ch;
251
+ continue;
252
+ }
253
+ if (!inQuote) {
254
+ if (ch === "(") {
255
+ depth++;
256
+ current += ch;
257
+ continue;
258
+ }
259
+ if (ch === ")") {
260
+ depth--;
261
+ current += ch;
262
+ continue;
263
+ }
264
+ if (ch === "," && depth === 0) {
265
+ parts.push(current.trim());
266
+ current = "";
267
+ continue;
268
+ }
269
+ }
270
+ current += ch;
271
+ }
272
+ if (current.trim()) parts.push(current.trim());
273
+
274
+ if (parts.length >= 2) {
275
+ return { when: parts[0]!, state: parts.slice(1).join(",") };
276
+ }
277
+ return undefined;
278
+ }
279
+
280
+ /**
281
+ * Parse an `effects:[...]` value into an array of effect spec strings.
282
+ *
283
+ * Supports:
284
+ * effects:[fadeIn]
285
+ * effects:[fadeIn, bounceIn]
286
+ * effects:[fadeIn(timingFunction:ease-out iterationCount:2)]
287
+ *
288
+ * Each element is kept as a raw string — the compiler's `normalizeEffectSpec`
289
+ * handles parsing the paren-based params. Returns `[]` on structural mismatch.
290
+ */
291
+ export function parseEffects(raw: string): string[] {
292
+ const s = raw.trim();
293
+ if (!s.startsWith("[") || !s.endsWith("]")) return [];
294
+ const body = s.slice(1, -1).trim();
295
+ if (!body) return [];
296
+
297
+ const results: string[] = [];
298
+ let current = "";
299
+ let depth = 0; // brackets/braces
300
+ let parenDepth = 0; // parens for params
301
+ let inQuote = false;
302
+
303
+ for (let i = 0; i < body.length; i++) {
304
+ const ch = body[i]!;
305
+ if (ch === '"') {
306
+ inQuote = !inQuote;
307
+ current += ch;
308
+ continue;
309
+ }
310
+ if (!inQuote) {
311
+ if (ch === "[" || ch === "{") {
312
+ depth++;
313
+ current += ch;
314
+ continue;
315
+ }
316
+ if (ch === "]" || ch === "}") {
317
+ depth = Math.max(0, depth - 1);
318
+ current += ch;
319
+ continue;
320
+ }
321
+ if (ch === "(") {
322
+ parenDepth++;
323
+ current += ch;
324
+ continue;
325
+ }
326
+ if (ch === ")") {
327
+ parenDepth = Math.max(0, parenDepth - 1);
328
+ current += ch;
329
+ continue;
330
+ }
331
+ if (ch === "," && depth === 0 && parenDepth === 0) {
332
+ const trimmed = current.trim();
333
+ if (trimmed) results.push(trimmed);
334
+ current = "";
335
+ continue;
336
+ }
337
+ }
338
+ current += ch;
339
+ }
340
+ const trimmed = current.trim();
341
+ if (trimmed) results.push(trimmed);
342
+
343
+ return results;
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Key/value dispatcher
348
+ // ---------------------------------------------------------------------------
349
+
350
+ /**
351
+ * Keys whose raw values should be preserved verbatim (no scalar coercion or
352
+ * object parsing). These are free-form text fields.
353
+ */
354
+ const RAW_STRING_KEYS = new Set(["instruction", "tts", "stt", "jsx", "prompt"]);
355
+
356
+ /**
357
+ * Keys whose values are lenient JSON objects/arrays.
358
+ */
359
+ const OBJECT_KEYS = new Set([
360
+ "props",
361
+ "imports",
362
+ "components",
363
+ "spots",
364
+ "customKeyframes",
365
+ ]);
366
+
367
+ /**
368
+ * Parse an array of tokens (from {@link splitTokens}) into a key/value bag.
369
+ *
370
+ * All attributes use the `key:value` form:
371
+ * `src:a.mp4`, `duration:2`, `on:(start, x.y=1)`, `effects:[fadeIn]`
372
+ *
373
+ * Value normalization is key-specific:
374
+ * - `layout` / `transition`: validated against the enum sets (throws on
375
+ * invalid), with `transition` accepting the merged `fade(0.5)` form
376
+ * - `waypoints` / `on` / `effects` / object keys: dispatched to their
377
+ * dedicated parsers
378
+ * - inline `{...}` / `[...]` values: parsed as lenient JSON
379
+ * - everything else: scalar coercion via {@link parseNumberMaybe}
380
+ *
381
+ * Throws {@link DslError} (with optional context) on unrecognized tokens or
382
+ * invalid enum values.
383
+ *
384
+ * @param tokens Token array, typically from `splitTokens(line)`.
385
+ * @param ctx Optional source context for error messages.
386
+ */
387
+ export function parseKeyValueTokens(
388
+ tokens: string[],
389
+ ctx?: ParseContext,
390
+ ): Record<string, unknown> {
391
+ const out: Record<string, unknown> = {};
392
+ let i = 0;
393
+
394
+ while (i < tokens.length) {
395
+ const token = tokens[i]!;
396
+ const idx = token.indexOf(":");
397
+ if (idx > 0) {
398
+ const key = token.slice(0, idx);
399
+ let rawVal = token.slice(idx + 1);
400
+ // If value after colon is empty, peek at next quoted token
401
+ if (!rawVal && i + 1 < tokens.length) {
402
+ const next = tokens[i + 1]!;
403
+ if (isQuoted(next)) {
404
+ rawVal = next;
405
+ i++; // consume the next token
406
+ }
407
+ }
408
+ let val: unknown = unquote(rawVal);
409
+
410
+ if (key === "layout") {
411
+ const s = String(val);
412
+ if (!LAYOUT_VALUES.has(s as (typeof LAYOUT_VALUES extends Set<infer T> ? T : never)))
413
+ fail(`invalid layout value: ${s}`, { ...ctx, token });
414
+ val = s;
415
+ }
416
+ if (key === "transition") {
417
+ const s = String(val);
418
+ // Support "name(time)" format e.g. "fade(0.5)" to merge transition + transitionTime
419
+ const parenMatch = s.match(/^(\w+)\((\d+(?:\.\d+)?)\)$/);
420
+ if (parenMatch) {
421
+ const [, name, timeStr] = parenMatch;
422
+ if (!TRANSITION_VALUES.has(name as (typeof TRANSITION_VALUES extends Set<infer T> ? T : never)))
423
+ fail(`invalid transition value: ${name}`, { ...ctx, token });
424
+ out["transition"] = name;
425
+ out["transitionTime"] = Number(timeStr);
426
+ i++;
427
+ continue;
428
+ }
429
+ if (!TRANSITION_VALUES.has(s as (typeof TRANSITION_VALUES extends Set<infer T> ? T : never)))
430
+ fail(`invalid transition value: ${s}`, { ...ctx, token });
431
+ }
432
+
433
+ if (key === "waypoints") val = parseWaypoints(String(val));
434
+ else if (OBJECT_KEYS.has(key)) val = parseProps(String(val));
435
+ else if (key === "on") val = parseOnSpec(String(val));
436
+ else if (key === "effects") val = parseEffects(String(val));
437
+ else if (!RAW_STRING_KEYS.has(key)) {
438
+ const strVal = String(val);
439
+ // If the value looks like an inline JSON object/array, parse it as props
440
+ if (strVal.startsWith("{") || strVal.startsWith("[")) {
441
+ val = parseProps(strVal);
442
+ } else {
443
+ val = parseNumberMaybe(strVal);
444
+ }
445
+ }
446
+ out[key] = val;
447
+ i++;
448
+ continue;
449
+ }
450
+
451
+ fail(`unrecognized token: ${token}`, { ...ctx, token });
452
+ }
453
+
454
+ return out;
455
+ }