Skip to content

Commit 7e402bd

Browse files
feat: --watch mode for render.ts + 14 render CLI tests
- Add --watch flag to render.ts with fs.watch file monitoring - Auto-re-renders when config.ts, timeline.ts, subtitles.ts, Root.tsx, or index.ts change - Re-extracts composition ID on Root.tsx changes - Add 14 tests for render.ts utilities (findProjectFile, extractCompositionId, parseFramesArg, findEntryPoint) - Total: 95 tests, all passing
1 parent 0cb380d commit 7e402bd

2 files changed

Lines changed: 210 additions & 17 deletions

File tree

src/cli/__tests__/render.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert";
3+
import { findProjectFile, extractCompositionId, parseFramesArg, findEntryPoint } from "../render";
4+
import fs from "fs";
5+
import path from "path";
6+
import os from "os";
7+
8+
describe("render findProjectFile", () => {
9+
it("returns the file path when given a .json file", () => {
10+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencut-test-"));
11+
const file = path.join(dir, "project.json");
12+
fs.writeFileSync(file, "{}");
13+
assert.strictEqual(findProjectFile(file), file);
14+
fs.rmSync(dir, { recursive: true });
15+
});
16+
17+
it("returns the file path when given a .yaml file", () => {
18+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencut-test-"));
19+
const file = path.join(dir, "project.yaml");
20+
fs.writeFileSync(file, "");
21+
assert.strictEqual(findProjectFile(file), file);
22+
fs.rmSync(dir, { recursive: true });
23+
});
24+
25+
it("finds project.json in a directory", () => {
26+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencut-test-"));
27+
fs.writeFileSync(path.join(dir, "project.json"), "{}");
28+
assert.strictEqual(findProjectFile(dir), path.join(dir, "project.json"));
29+
fs.rmSync(dir, { recursive: true });
30+
});
31+
32+
it("prefers project.json over project.yaml", () => {
33+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencut-test-"));
34+
fs.writeFileSync(path.join(dir, "project.json"), "{}");
35+
fs.writeFileSync(path.join(dir, "project.yaml"), "");
36+
assert.strictEqual(findProjectFile(dir), path.join(dir, "project.json"));
37+
fs.rmSync(dir, { recursive: true });
38+
});
39+
40+
it("throws when no project file exists", () => {
41+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencut-test-"));
42+
assert.throws(() => findProjectFile(dir), /No project\.json/);
43+
fs.rmSync(dir, { recursive: true });
44+
});
45+
});
46+
47+
describe("render extractCompositionId", () => {
48+
it("extracts id from Root.tsx content", () => {
49+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencut-test-"));
50+
const file = path.join(dir, "Root.tsx");
51+
fs.writeFileSync(file, `<Composition id="MyVideo" component={MyVideo} />`);
52+
assert.strictEqual(extractCompositionId(file), "MyVideo");
53+
fs.rmSync(dir, { recursive: true });
54+
});
55+
56+
it("extracts the first id when multiple exist", () => {
57+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencut-test-"));
58+
const file = path.join(dir, "Root.tsx");
59+
fs.writeFileSync(
60+
file,
61+
`<Composition id="First" component={First} /><Composition id="Second" component={Second} />`
62+
);
63+
assert.strictEqual(extractCompositionId(file), "First");
64+
fs.rmSync(dir, { recursive: true });
65+
});
66+
67+
it("throws when no id is found", () => {
68+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencut-test-"));
69+
const file = path.join(dir, "Root.tsx");
70+
fs.writeFileSync(file, `export const Root = () => <div>hello</div>;`);
71+
assert.throws(() => extractCompositionId(file), /Could not find composition id/);
72+
fs.rmSync(dir, { recursive: true });
73+
});
74+
});
75+
76+
describe("render parseFramesArg", () => {
77+
it("parses --frames 0-149", () => {
78+
assert.strictEqual(parseFramesArg(["--frames", "0-149"]), "0-149");
79+
});
80+
81+
it("parses --frames=0-149", () => {
82+
assert.strictEqual(parseFramesArg(["--frames=0-149"]), "0-149");
83+
});
84+
85+
it("returns null when not present", () => {
86+
assert.strictEqual(parseFramesArg(["--preview"]), null);
87+
});
88+
89+
it("returns null for empty args", () => {
90+
assert.strictEqual(parseFramesArg([]), null);
91+
});
92+
});
93+
94+
describe("render findEntryPoint", () => {
95+
it("returns index.ts path when it exists", () => {
96+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencut-test-"));
97+
fs.writeFileSync(path.join(dir, "index.ts"), "");
98+
assert.strictEqual(findEntryPoint(dir), path.join(dir, "index.ts"));
99+
fs.rmSync(dir, { recursive: true });
100+
});
101+
102+
it("throws when index.ts does not exist", () => {
103+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencut-test-"));
104+
assert.throws(() => findEntryPoint(dir), /No index\.ts/);
105+
fs.rmSync(dir, { recursive: true });
106+
});
107+
});

src/cli/render.ts

Lines changed: 103 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
* Smarter render CLI for OpenCut projects.
44
*
55
* Usage:
6-
* npx ts-node src/cli/render.ts <project> [--preview] [--frames 0-149]
6+
* npx ts-node src/cli/render.ts <project> [--preview] [--frames 0-149] [--watch]
77
*
88
* Examples:
99
* npx ts-node src/cli/render.ts src/my-project/project.json
1010
* npx ts-node src/cli/render.ts src/my-project --preview
1111
* npx ts-node src/cli/render.ts src/my-project --frames 0-149
12+
* npx ts-node src/cli/render.ts src/my-project --watch
1213
*/
1314

1415
import { execSync } from "child_process";
@@ -70,17 +71,112 @@ export function parseFramesArg(args: string[]): string | null {
7071
return null;
7172
}
7273

74+
function buildRenderCommand(
75+
entryPoint: string,
76+
compositionId: string,
77+
outputPath: string,
78+
frames: string | null
79+
): string {
80+
let cmd = `timeout 10m npx remotion render ${entryPoint} ${compositionId} ${outputPath}`;
81+
if (frames) {
82+
cmd += ` --frames=${frames}`;
83+
}
84+
return cmd;
85+
}
86+
87+
function runRender(
88+
entryPoint: string,
89+
compositionId: string,
90+
outputPath: string,
91+
frames: string | null,
92+
projectName?: string
93+
): void {
94+
const cmd = buildRenderCommand(entryPoint, compositionId, outputPath, frames);
95+
96+
console.log(`\nRendering "${projectName || compositionId}"...`);
97+
console.log(` Entry: ${entryPoint}`);
98+
console.log(` Comp: ${compositionId}`);
99+
console.log(` Output: ${outputPath}`);
100+
if (frames) {
101+
console.log(` Frames: ${frames}`);
102+
}
103+
console.log(`> ${cmd}`);
104+
105+
execSync(cmd, { stdio: "inherit" });
106+
}
107+
108+
function watchProject(
109+
dir: string,
110+
entryPoint: string,
111+
compositionId: string,
112+
outputPath: string,
113+
frames: string | null,
114+
projectName?: string
115+
): void {
116+
const watchFiles = [
117+
path.join(dir, "config.ts"),
118+
path.join(dir, "timeline.ts"),
119+
path.join(dir, "subtitles.ts"),
120+
path.join(dir, "Root.tsx"),
121+
path.join(dir, "index.ts"),
122+
].filter((f) => fs.existsSync(f));
123+
124+
console.log(`\n👁 Watch mode enabled. Watching ${watchFiles.length} file(s) for changes:`);
125+
watchFiles.forEach((f) => console.log(` - ${path.relative(process.cwd(), f)}`));
126+
console.log(`\nPress Ctrl+C to stop.\n`);
127+
128+
// Render once immediately
129+
try {
130+
runRender(entryPoint, compositionId, outputPath, frames, projectName);
131+
console.log("\n✅ Initial render complete. Waiting for changes...\n");
132+
} catch {
133+
console.log("\n⚠️ Initial render failed. Fix errors and save to retry.\n");
134+
}
135+
136+
const watchers = watchFiles.map((file) =>
137+
fs.watch(file, (eventType) => {
138+
if (eventType === "change") {
139+
console.log(`\n🔄 File changed: ${path.relative(process.cwd(), file)}`);
140+
try {
141+
// Re-extract composition ID in case Root.tsx changed
142+
let currentCompId = compositionId;
143+
try {
144+
const rootTsx = path.join(dir, "Root.tsx");
145+
currentCompId = extractCompositionId(rootTsx);
146+
} catch {
147+
// keep existing
148+
}
149+
runRender(entryPoint, currentCompId, outputPath, frames, projectName);
150+
console.log("\n✅ Render complete. Waiting for changes...\n");
151+
} catch {
152+
console.log("\n❌ Render failed. Fix errors and save to retry.\n");
153+
}
154+
}
155+
})
156+
);
157+
158+
process.on("SIGINT", () => {
159+
console.log("\n\n👋 Stopping watch mode...");
160+
watchers.forEach((w) => w.close());
161+
process.exit(0);
162+
});
163+
164+
// Keep process alive
165+
setInterval(() => {}, 1000);
166+
}
167+
73168
function main() {
74169
const args = process.argv.slice(2);
75170

76171
if (args.length === 0 || args[0].startsWith("-")) {
77-
console.error("Usage: npx ts-node src/cli/render.ts <project> [--preview] [--frames 0-149]");
172+
console.error("Usage: npx ts-node src/cli/render.ts <project> [--preview] [--frames 0-149] [--watch]");
78173
process.exit(1);
79174
}
80175

81176
const projectArg = args[0];
82177
const preview = args.includes("--preview");
83178
const frames = parseFramesArg(args);
179+
const watch = args.includes("--watch");
84180

85181
// Resolve the project directory
86182
let dir: string;
@@ -112,8 +208,7 @@ function main() {
112208
compositionId = extractCompositionId(rootTsx);
113209
} catch (e) {
114210
// Fallback: derive from directory or project name
115-
const fallbackName =
116-
project?.name || path.basename(dir);
211+
const fallbackName = project?.name || path.basename(dir);
117212
compositionId = fallbackName
118213
.replace(/[^a-zA-Z0-9]+/g, " ")
119214
.split(" ")
@@ -140,21 +235,12 @@ function main() {
140235
.toLowerCase();
141236
const outputPath = path.join(outDir, `${safeName}.mp4`);
142237

143-
let cmd = `timeout 10m npx remotion render ${entryPoint} ${compositionId} ${outputPath}`;
144-
if (frames) {
145-
cmd += ` --frames=${frames}`;
146-
}
147-
148-
console.log(`Rendering "${project?.name || compositionId}"...`);
149-
console.log(` Entry: ${entryPoint}`);
150-
console.log(` Comp: ${compositionId}`);
151-
console.log(` Output: ${outputPath}`);
152-
if (frames) {
153-
console.log(` Frames: ${frames}`);
238+
if (watch) {
239+
watchProject(dir, entryPoint, compositionId, outputPath, frames, project?.name);
240+
return;
154241
}
155-
console.log(`> ${cmd}`);
156242

157-
execSync(cmd, { stdio: "inherit" });
243+
runRender(entryPoint, compositionId, outputPath, frames, project?.name);
158244
}
159245

160246
if (require.main === module) {

0 commit comments

Comments
 (0)