@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
package/tests/utils.ts ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Test utilities for the Remotion Engine integration tests.
3
+ *
4
+ * Provides helper functions for rendering stream trees, extracting frames,
5
+ * and running STT (speech-to-text) on rendered video output.
6
+ */
7
+ import { execSync, execFileSync } from "node:child_process";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, statSync } from "node:fs";
9
+ import { resolve, dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { config } from "dotenv";
12
+ import { DEFAULT_STT_CLI } from "../src/config.mjs";
13
+
14
+ // Load .env from project root so env vars (GOOGLE_MAPS_API_KEY, etc.) are available
15
+ config({ path: resolve(fileURLToPath(import.meta.url), "../../.env") });
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = dirname(__filename);
19
+ export const ROOT = resolve(__dirname, "..");
20
+ export const OUT_DIR = resolve(__dirname, "out");
21
+ export const FIXTURES_DIR = resolve(__dirname, "fixtures");
22
+
23
+ // Ensure output directory exists
24
+ mkdirSync(OUT_DIR, { recursive: true });
25
+
26
+ // ── Configuration ──────────────────────────────────────────────────────────
27
+
28
+ /** Default render timeout (5 min) */
29
+ export const RENDER_TIMEOUT = 300_000;
30
+
31
+ /** Default STT timeout (2 min) */
32
+ export const STT_TIMEOUT = 120_000;
33
+
34
+ // ── Render Helpers ─────────────────────────────────────────────────────────
35
+
36
+ export interface RenderOptions {
37
+ /** Composition ID (default: "Root") */
38
+ composition?: string;
39
+ /** Output filename (relative to tests/out/) */
40
+ outputName?: string;
41
+ /** Timeout in ms */
42
+ timeout?: number;
43
+ /** Whether to show verbose output */
44
+ verbose?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Render a stream tree JSON fixture to an MP4 video.
49
+ *
50
+ * @param fixturePath - Path to the JSON fixture file (absolute or relative to ROOT)
51
+ * @param options - Render options
52
+ * @returns The absolute path to the rendered MP4 file
53
+ */
54
+ export function renderFixture(
55
+ fixturePath: string,
56
+ options: RenderOptions = {},
57
+ ): string {
58
+ const {
59
+ composition = "Root",
60
+ outputName = `${basename(fixturePath).replace(/\.json$/, "")}.mp4`,
61
+ timeout = RENDER_TIMEOUT,
62
+ verbose = false,
63
+ } = options;
64
+
65
+ const absFixturePath = fixturePath.startsWith("/")
66
+ ? fixturePath
67
+ : resolve(ROOT, fixturePath);
68
+
69
+ if (!existsSync(absFixturePath)) {
70
+ throw new Error(`Fixture not found: ${absFixturePath}`);
71
+ }
72
+
73
+ const raw = JSON.parse(readFileSync(absFixturePath, "utf-8"));
74
+ const streamTree = raw.root ?? raw;
75
+
76
+ const tmpProps = resolve(OUT_DIR, `_props-${outputName}.json`);
77
+ writeFileSync(tmpProps, JSON.stringify({ root: streamTree }));
78
+
79
+ const outPath = resolve(OUT_DIR, outputName);
80
+ mkdirSync(dirname(outPath), { recursive: true });
81
+
82
+ const cmd = `npx remotion render ${composition} "${outPath}" --props="${tmpProps}" --config=remotion.config.ts --log=error`;
83
+
84
+ if (verbose) {
85
+ console.log(`\n▶ Rendering: ${cmd}`);
86
+ }
87
+
88
+ try {
89
+ execSync(cmd, {
90
+ cwd: ROOT,
91
+ stdio: verbose ? "inherit" : "pipe",
92
+ timeout,
93
+ env: { ...process.env, CI: "true" },
94
+ });
95
+ } catch (err: any) {
96
+ const stderr = err.stderr?.toString() || "";
97
+ const stdout = err.stdout?.toString() || "";
98
+ if (verbose) {
99
+ console.error("RENDER ERROR:", stderr);
100
+ }
101
+ // Check if the error is just "still processing" or a real error
102
+ if (!existsSync(outPath)) {
103
+ throw new Error(
104
+ `Render failed for ${fixturePath}: ${err.message}\n${stderr.slice(0, 500)}`,
105
+ );
106
+ }
107
+ }
108
+
109
+ if (!existsSync(outPath)) {
110
+ throw new Error(`Output file not created: ${outPath}`);
111
+ }
112
+
113
+ // Clean up temp props
114
+ try { rmSync(tmpProps); } catch {}
115
+
116
+ return outPath;
117
+ }
118
+
119
+ /**
120
+ * Render a scene-based video.json fixture to an MP4 video.
121
+ * Uses the Main16x9 composition.
122
+ */
123
+ export function renderScenes(
124
+ fixturePath: string,
125
+ options: RenderOptions = {},
126
+ ): string {
127
+ const {
128
+ composition = "Main16x9",
129
+ outputName = `scenes-${basename(fixturePath).replace(/\.json$/, "")}.mp4`,
130
+ timeout = RENDER_TIMEOUT,
131
+ verbose = false,
132
+ } = options;
133
+
134
+ const absFixturePath = fixturePath.startsWith("/")
135
+ ? fixturePath
136
+ : resolve(ROOT, fixturePath);
137
+
138
+ if (!existsSync(absFixturePath)) {
139
+ throw new Error(`Fixture not found: ${absFixturePath}`);
140
+ }
141
+
142
+ const props = readFileSync(absFixturePath, "utf-8");
143
+
144
+ const tmpProps = resolve(OUT_DIR, `_props-${outputName}.json`);
145
+ writeFileSync(tmpProps, props);
146
+
147
+ const outPath = resolve(OUT_DIR, outputName);
148
+ mkdirSync(dirname(outPath), { recursive: true });
149
+
150
+ const cmd = `npx remotion render ${composition} "${outPath}" --props="${tmpProps}" --config=remotion.config.ts --log=error`;
151
+
152
+ if (verbose) {
153
+ console.log(`\n▶ Rendering scenes: ${cmd}`);
154
+ }
155
+
156
+ try {
157
+ execSync(cmd, {
158
+ cwd: ROOT,
159
+ stdio: verbose ? "inherit" : "pipe",
160
+ timeout,
161
+ env: { ...process.env, CI: "true" },
162
+ });
163
+ } catch (err: any) {
164
+ const stderr = err.stderr?.toString() || "";
165
+ if (!existsSync(outPath)) {
166
+ throw new Error(
167
+ `Scene render failed for ${fixturePath}: ${err.message}\n${stderr.slice(0, 500)}`,
168
+ );
169
+ }
170
+ }
171
+
172
+ if (!existsSync(outPath)) {
173
+ throw new Error(`Output file not created: ${outPath}`);
174
+ }
175
+
176
+ try { rmSync(tmpProps); } catch {}
177
+
178
+ return outPath;
179
+ }
180
+
181
+ // ── Video Analysis ─────────────────────────────────────────────────────────
182
+
183
+ export interface VideoInfo {
184
+ path: string;
185
+ durationSec: number;
186
+ width: number;
187
+ height: number;
188
+ fps: number;
189
+ hasAudio: boolean;
190
+ fileSizeBytes: number;
191
+ }
192
+
193
+ /**
194
+ * Get video metadata using ffprobe.
195
+ */
196
+ export function getVideoInfo(videoPath: string): VideoInfo {
197
+ if (!existsSync(videoPath)) {
198
+ throw new Error(`Video not found: ${videoPath}`);
199
+ }
200
+
201
+ const ffprobe = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`;
202
+ const output = execSync(ffprobe, { encoding: "utf-8" });
203
+ const data = JSON.parse(output);
204
+
205
+ const videoStream = data.streams?.find((s: any) => s.codec_type === "video");
206
+ const audioStream = data.streams?.find((s: any) => s.codec_type === "audio");
207
+
208
+ const durationSec = parseFloat(data.format?.duration || "0");
209
+ const frameRate = videoStream?.avg_frame_rate || videoStream?.r_frame_rate || "30/1";
210
+ const [num, den] = frameRate.split("/").map(Number);
211
+
212
+ return {
213
+ path: videoPath,
214
+ durationSec,
215
+ width: videoStream?.width || 0,
216
+ height: videoStream?.height || 0,
217
+ fps: den ? Math.round(num / den) : 30,
218
+ hasAudio: !!audioStream,
219
+ fileSizeBytes: parseInt(data.format?.size || "0", 10),
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Extract a specific frame from a video as PNG.
225
+ * @param videoPath - Path to the video
226
+ * @param timeSeconds - Time offset in seconds
227
+ * @param outputPath - Where to save the PNG
228
+ * @returns The output path
229
+ */
230
+ export function extractFrame(
231
+ videoPath: string,
232
+ timeSeconds: number,
233
+ outputPath: string,
234
+ ): string {
235
+ mkdirSync(dirname(outputPath), { recursive: true });
236
+ const cmd = `ffmpeg -y -ss ${timeSeconds} -i "${videoPath}" -vframes 1 -f image2 "${outputPath}" 2>/dev/null`;
237
+ execSync(cmd, { stdio: "pipe" });
238
+ if (!existsSync(outputPath)) {
239
+ throw new Error(`Frame extraction failed at ${timeSeconds}s for ${videoPath}`);
240
+ }
241
+ return outputPath;
242
+ }
243
+
244
+ /**
245
+ * Extract audio from a video as WAV.
246
+ * @param videoPath - Path to the video
247
+ * @param outputPath - Where to save the WAV
248
+ * @returns The output path
249
+ */
250
+ export function extractAudio(
251
+ videoPath: string,
252
+ outputPath: string,
253
+ ): string {
254
+ mkdirSync(dirname(outputPath), { recursive: true });
255
+ const cmd = `ffmpeg -y -i "${videoPath}" -vn -acodec pcm_s16le -ar 16000 -ac 1 "${outputPath}" 2>/dev/null`;
256
+ execSync(cmd, { stdio: "pipe" });
257
+ if (!existsSync(outputPath)) {
258
+ throw new Error(`Audio extraction failed for ${videoPath}`);
259
+ }
260
+ return outputPath;
261
+ }
262
+
263
+ /**
264
+ * Run STT on an audio file using the configured STT CLI (DEFAULT_STT_CLI).
265
+ * @returns The transcribed text
266
+ */
267
+ export function transcribeAudio(audioPath: string, sttCli?: string): string {
268
+ const cli = sttCli || DEFAULT_STT_CLI;
269
+ if (!cli) return "[stt not configured]";
270
+
271
+ const outputDir = dirname(audioPath);
272
+ const cmd = cli.replace(/\{input\}/g, audioPath).replace(/\{output\}/g, outputDir);
273
+ try {
274
+ execSync(cmd, { stdio: "pipe", timeout: STT_TIMEOUT, cwd: dirname(audioPath) });
275
+ // Whisper STT outputs a .vtt file next to the audio file
276
+ const baseName = audioPath.replace(/\.\w+$/, "");
277
+ const vttPath = baseName + ".vtt";
278
+ if (existsSync(vttPath)) {
279
+ const vtt = readFileSync(vttPath, "utf-8").trim();
280
+ try { rmSync(vttPath); } catch {}
281
+ // Extract plain text from VTT (strip timestamps and headers)
282
+ return vtt
283
+ .replace(/^WEBVTT.*\n?/m, "")
284
+ .replace(/\d{2}:\d{2}:\d{2}\.\d{3}.*/g, "")
285
+ .replace(/-->.*/g, "")
286
+ .replace(/\n{2,}/g, "\n")
287
+ .trim();
288
+ }
289
+ return "";
290
+ } catch (err: any) {
291
+ console.warn(`STT failed for ${audioPath}: ${err.message}`);
292
+ return `[stt error: ${err.message}]`;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Get the pixel data of a specific frame (first row center pixel) to verify
298
+ * the video is not blank/black.
299
+ */
300
+ export function getFramePixelInfo(
301
+ framePath: string,
302
+ x = -1,
303
+ y = -1,
304
+ ): { r: number; g: number; b: number } | null {
305
+ if (!existsSync(framePath)) return null;
306
+
307
+ // Use ffmpeg to get pixel info
308
+ const fx = x >= 0 ? x : 320; // center-ish for 640 width
309
+ const fy = y >= 0 ? y : 240; // center-ish for 480 height
310
+ const cmd = `ffmpeg -y -i "${framePath}" -vf "crop=1:1:${fx}:${fy}" -vframes 1 -f rawvideo -pix_fmt rgb24 pipe: 2>/dev/null`;
311
+
312
+ try {
313
+ const output = execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] });
314
+ if (output.length >= 3) {
315
+ return { r: output[0], g: output[1], b: output[2] };
316
+ }
317
+ } catch {}
318
+ return null;
319
+ }
320
+
321
+ /**
322
+ * Verify a frame is not entirely black (has visual content).
323
+ */
324
+ export function isFrameNonBlank(framePath: string): boolean {
325
+ const pixel = getFramePixelInfo(framePath);
326
+ if (!pixel) return false;
327
+ return pixel.r > 10 || pixel.g > 10 || pixel.b > 10;
328
+ }
329
+
330
+ /**
331
+ * Get the file size of a frame in bytes.
332
+ * A non-blank frame will be significantly larger than a blank one.
333
+ */
334
+ export function getFrameFileSize(framePath: string): number {
335
+ if (!existsSync(framePath)) return 0;
336
+ try {
337
+ return statSync(framePath).size;
338
+ } catch {
339
+ return 0;
340
+ }
341
+ }
342
+
343
+ // ── Duration Calculation Test Helpers ──────────────────────────────────────
344
+
345
+ /**
346
+ * Get the expected duration of a stream tree fixture in seconds.
347
+ */
348
+ export function getExpectedDuration(fixtureData: any): number {
349
+ if (fixtureData.durationInSeconds) return fixtureData.durationInSeconds;
350
+
351
+ // For root with series children, sum the durations
352
+ if (fixtureData.type === "root" || fixtureData.type === "folder") {
353
+ if (fixtureData.isSeries && Array.isArray(fixtureData.children)) {
354
+ let total = 0;
355
+ for (const child of fixtureData.children) {
356
+ const dur = getExpectedDuration(child);
357
+ total += dur;
358
+ }
359
+ // Subtract transition overlaps
360
+ const transitionTime = fixtureData.transitionTime ?? 0.5;
361
+ const transitions = fixtureData.children.length - 1;
362
+ if (transitions > 0 && fixtureData.transition) {
363
+ total -= transitions * transitionTime;
364
+ }
365
+ return Math.max(1, total);
366
+ }
367
+ if (Array.isArray(fixtureData.children)) {
368
+ let maxDur = 0;
369
+ for (const child of fixtureData.children) {
370
+ const dur = getExpectedDuration(child);
371
+ if (!child.isBackground) {
372
+ maxDur = Math.max(maxDur, dur);
373
+ }
374
+ }
375
+ return Math.max(1, maxDur);
376
+ }
377
+ }
378
+
379
+ // Leaf action duration
380
+ if (fixtureData.actions?.length) {
381
+ return Math.max(...fixtureData.actions.map((a: any) => a.end ?? 1));
382
+ }
383
+
384
+ return 1;
385
+ }
386
+
387
+ // ── Helpers ────────────────────────────────────────────────────────────────
388
+
389
+ function basename(path: string): string {
390
+ return path.split("/").pop() || path.split("\\").pop() || path;
391
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ // Integration tests can be slow — 5 min timeout per test
6
+ testTimeout: 600_000,
7
+ hookTimeout: 600_000,
8
+ // Don't run tests in parallel — rendering is resource-intensive
9
+ pool: "forks",
10
+ fileParallelism: false,
11
+ // Print detailed output
12
+ reporters: ["verbose"],
13
+ // Pass through environment
14
+ env: {
15
+ CI: "true",
16
+ },
17
+ },
18
+ });
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ // Integration tests render videos — long timeout
6
+ testTimeout: 600_000,
7
+ hookTimeout: 600_000,
8
+ // Only run the integration test file
9
+ include: ["tests/render.test.ts"],
10
+ // Don't run in parallel — rendering is resource-intensive
11
+ pool: "forks",
12
+ fileParallelism: false,
13
+ reporters: ["verbose"],
14
+ env: { CI: "true" },
15
+ },
16
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "resolveJsonModule": true,
13
+ "allowImportingTsExtensions": false,
14
+ "noEmit": true,
15
+ "isolatedModules": true,
16
+ "forceConsistentCasingInFileNames": true,
17
+ "types": ["node"]
18
+ },
19
+ "include": ["src/**/*"]
20
+ }