@open-take/compositor 0.1.0 → 0.1.2

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.
package/src/math.ts CHANGED
@@ -6,8 +6,8 @@ import type { BBox, Pt, TakeComposition } from "./types";
6
6
 
7
7
  // smootherstep 6t^5-15t^4+10t^3
8
8
  export function smoother(t: number): number {
9
- t = Math.max(0, Math.min(1, t));
10
- return t * t * t * (t * (t * 6 - 15) + 10);
9
+ const clamped = Math.max(0, Math.min(1, t));
10
+ return clamped * clamped * clamped * (clamped * (clamped * 6 - 15) + 10);
11
11
  }
12
12
 
13
13
  // Cubic-bezier easing y(x) with endpoints (0,0),(1,1) and control points
package/src/render.ts CHANGED
@@ -3,25 +3,42 @@
3
3
  //
4
4
  // Runs revideo headless (vite + chromium + ffmpeg). The renderer resolves
5
5
  // everything relative to process.cwd(), and injects `projectFile` verbatim
6
- // as an import specifier — so we chdir to the package root and use the
7
- // vite-root-absolute "/src/scene/project.ts" (a bare specifier hangs the
8
- // renderer forever; see spike-revideo/VERDICT.md).
6
+ // as an import specifier — so the render runs from a directory laid out like
7
+ // the package (src/ + public/) with the vite-root-absolute
8
+ // "/src/scene/project.ts" (a bare specifier hangs the renderer forever; see
9
+ // spike-revideo/VERDICT.md).
10
+ //
11
+ // That directory is a per-render SCRATCH COPY in the tmp dir, never the
12
+ // installed package: a render used to write capture.mp4, .composition.json and
13
+ // out-render/ into node_modules, which breaks read-only installs outright and
14
+ // lets two renders read each other's composition. See prepareScratch.
9
15
 
10
16
  import { spawn } from "node:child_process";
11
- import { copyFile, mkdir, writeFile } from "node:fs/promises";
12
- import { dirname, resolve } from "node:path";
17
+ import { existsSync, realpathSync } from "node:fs";
18
+ import {
19
+ copyFile,
20
+ mkdir,
21
+ mkdtemp,
22
+ readFile,
23
+ readdir,
24
+ rm,
25
+ symlink,
26
+ unlink,
27
+ writeFile,
28
+ } from "node:fs/promises";
29
+ import { createRequire } from "node:module";
30
+ import { tmpdir } from "node:os";
31
+ import { dirname, join, resolve, sep } from "node:path";
13
32
  import { fileURLToPath } from "node:url";
14
- import { renderVideo } from "@revideo/renderer";
15
- import { resolveFfmpeg } from "./ffmpeg";
33
+ import { renderVideo } from "@open-take/revideo-renderer";
34
+ import { repairBundledMediaPermissions, resolveFfmpeg } from "./ffmpeg";
16
35
  import { type PlanOpts, planComposition } from "./plan";
17
36
  import { type CaptureLog, type TakeComposition, motionBlurActive } from "./types";
18
37
  import { type CompositionIssue, formatIssues, validateComposition } from "./validate";
19
38
 
20
39
  // dist/index.js -> package root
21
40
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
22
- const PUBLIC_MP4 = resolve(PKG_ROOT, "public/capture.mp4");
23
- const COMP_JSON = resolve(PKG_ROOT, "src/scene/.composition.json");
24
- const RENDER_OUT = "out-render"; // relative to cwd (= PKG_ROOT at render time)
41
+ const RENDER_OUT = "out-render"; // relative to cwd (= the scratch dir at render time)
25
42
 
26
43
  export type RenderTakeOpts = {
27
44
  /** input capture video (webm or mp4) */
@@ -35,10 +52,9 @@ export type RenderTakeOpts = {
35
52
  planOpts?: PlanOpts;
36
53
  logProgress?: boolean;
37
54
  /** Chrome binary for the headless render. Pass the same Chrome-for-Testing
38
- * the capture path uses so a single browser serves both stages (no second
39
- * download). revideo forwards this to puppeteer.launch's executablePath;
40
- * if unset, revideo's bundled puppeteer resolves its own. */
41
- chromePath?: string;
55
+ * the capture path uses so a single browser serves both stages. The
56
+ * higher-level runtime resolves and supplies this to puppeteer-core. */
57
+ chromePath: string;
42
58
  /** the capture log, for cross-checking that an edited composition didn't
43
59
  * drift an action's capture-locked tMs (see validateComposition). Optional —
44
60
  * the structural checks run regardless. */
@@ -66,7 +82,9 @@ function run(cmd: string, args: string[]): Promise<void> {
66
82
  return new Promise((res, rej) => {
67
83
  const c = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
68
84
  let err = "";
69
- c.stderr.on("data", (d) => (err += d));
85
+ c.stderr.on("data", (d) => {
86
+ err += d;
87
+ });
70
88
  c.on("error", rej);
71
89
  c.on("close", (code) => (code === 0 ? res() : rej(new Error(`${cmd} exited ${code}: ${err}`))));
72
90
  });
@@ -134,9 +152,113 @@ async function motionBlurMp4(
134
152
  ]);
135
153
  }
136
154
 
155
+ // --- per-render scratch dir --------------------------------------------------
156
+
157
+ /** The nearest node_modules above the installed package. Symlinked into the
158
+ * scratch dir so the copied scene can still resolve `@revideo/*` — pnpm keeps
159
+ * a node_modules beside the package, npm/yarn hoist it to the project root, so
160
+ * walk up rather than assume either. */
161
+ function hostNodeModules(): string | null {
162
+ let dir = PKG_ROOT;
163
+ for (;;) {
164
+ const candidate = join(dir, "node_modules");
165
+ if (existsSync(candidate)) return candidate;
166
+ const up = dirname(dir);
167
+ if (up === dir) return null;
168
+ dir = up;
169
+ }
170
+ }
171
+
172
+ /** The whole dependency tree in one directory, for vite's fs.allow. Vite serves
173
+ * realpaths, and pnpm's are under `<root>/node_modules/.pnpm/…`, so the first
174
+ * `node_modules` segment of a resolved dependency covers every layout. */
175
+ function depsRoot(): string | null {
176
+ try {
177
+ const real = realpathSync(createRequire(import.meta.url).resolve("@revideo/core"));
178
+ const parts = real.split(sep);
179
+ const i = parts.indexOf("node_modules");
180
+ return i === -1 ? null : parts.slice(0, i + 1).join(sep);
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /** Copy a tree WITHOUT inheriting its permission bits. `fs.cp` preserves mode,
187
+ * so copying out of a read-only install yields a read-only copy we then can't
188
+ * write the composition into. Re-creating each file gives us the umask
189
+ * default instead. The scene tree is a handful of small source files. */
190
+ async function copyWritable(from: string, to: string): Promise<void> {
191
+ await mkdir(to, { recursive: true });
192
+ for (const e of await readdir(from, { withFileTypes: true })) {
193
+ const src = join(from, e.name);
194
+ const dst = join(to, e.name);
195
+ if (e.isDirectory()) await copyWritable(src, dst);
196
+ else if (e.isFile()) await writeFile(dst, await readFile(src));
197
+ }
198
+ }
199
+
200
+ async function cleanupScratch(dir: string): Promise<void> {
201
+ // Drop the node_modules LINK first and by name: recursive deletion around a
202
+ // link to the real dependency tree deserves an explicit safety boundary.
203
+ await unlink(join(dir, "node_modules")).catch(() => {});
204
+ if (!process.env.OPEN_TAKE_KEEP_SCRATCH) {
205
+ await rm(dir, { recursive: true, force: true });
206
+ } else {
207
+ process.stderr.write(`render scratch kept: ${dir}\n`);
208
+ }
209
+ }
210
+
211
+ /** Build the throwaway directory this render runs in: the package's `src/`
212
+ * (the scene and everything it imports), this render's composition, the
213
+ * normalised capture as vite's public asset, and a node_modules link. */
214
+ async function prepareScratch(composition: TakeComposition, videoPath: string): Promise<string> {
215
+ const dir = await mkdtemp(join(tmpdir(), "open-take-render-"));
216
+ try {
217
+ await copyWritable(join(PKG_ROOT, "src"), join(dir, "src"));
218
+ await writeFile(
219
+ join(dir, "src", "scene", ".composition.json"),
220
+ JSON.stringify(composition, null, 2),
221
+ );
222
+ const nm = hostNodeModules();
223
+ if (!nm) throw new Error("render: could not locate node_modules for the scene's imports");
224
+ // "junction" is the Windows directory link that doesn't need admin rights;
225
+ // ignored on POSIX.
226
+ await symlink(nm, join(dir, "node_modules"), "junction");
227
+ // fps follows the composition: the render grid must match the source, or a
228
+ // hi-fps capture is decimated before the scene ever sees it.
229
+ await toMp4(videoPath, join(dir, "public", "capture.mp4"), composition.output.fps);
230
+ return dir;
231
+ } catch (error) {
232
+ await cleanupScratch(dir);
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ // process.chdir is process-global, so two renders in one process cannot run
238
+ // concurrently no matter how isolated their directories are — the scratch dirs
239
+ // remove the shared STATE, this removes the interleaving. (True parallelism
240
+ // needs the renderer off cwd, or one child process per render.)
241
+ let renderQueue: Promise<unknown> = Promise.resolve();
242
+
137
243
  export async function renderTake(
138
244
  opts: RenderTakeOpts,
139
245
  ): Promise<{ mp4Path: string; compositionPath: string }> {
246
+ const run = renderQueue.then(
247
+ () => renderTakeExclusive(opts),
248
+ () => renderTakeExclusive(opts),
249
+ );
250
+ renderQueue = run.catch(() => {});
251
+ return run;
252
+ }
253
+
254
+ async function renderTakeExclusive(
255
+ opts: RenderTakeOpts,
256
+ ): Promise<{ mp4Path: string; compositionPath: string }> {
257
+ if (!opts.chromePath) {
258
+ throw new Error(
259
+ "renderTake: `chromePath` is required; use @open-take/runtime to resolve managed Chrome automatically",
260
+ );
261
+ }
140
262
  const composition: TakeComposition =
141
263
  opts.composition ??
142
264
  planComposition(
@@ -164,74 +286,86 @@ export async function renderTake(
164
286
  );
165
287
  }
166
288
 
167
- // 1. serve the capture as /capture.mp4 (vite public dir under PKG_ROOT)
168
- await toMp4(opts.videoPath, PUBLIC_MP4, composition.output.fps);
169
-
170
- // 2. hand the composition to the scene (static import, rewritten per render)
171
- await mkdir(dirname(COMP_JSON), { recursive: true });
172
- await writeFile(COMP_JSON, JSON.stringify(composition, null, 2));
289
+ // Revideo spawns its bundled ffprobe directly. Repair installer permissions
290
+ // here so published consumers are protected even though they do not run the
291
+ // monorepo root's postinstall script.
292
+ await repairBundledMediaPermissions();
173
293
 
174
- // 3. render headless, with cwd pinned to the package root.
175
- // revideo's @revideo/telemetry phones home to PostHog by default; this is an
176
- // all-local tool, so default it OFF (an explicit user-set value still wins).
177
- if (process.env.DISABLE_TELEMETRY === undefined) process.env.DISABLE_TELEMETRY = "true";
178
- const prevCwd = process.cwd();
179
- process.chdir(PKG_ROOT);
180
- let produced: string;
294
+ // 1. lay out this render's own directory (scene + composition + capture)
295
+ const scratch = await prepareScratch(composition, opts.videoPath);
296
+ const deps = depsRoot();
181
297
  try {
182
- produced = await renderVideo({
183
- projectFile: "/src/scene/project.ts",
184
- settings: {
185
- outFile: "take.mp4",
186
- outDir: RENDER_OUT,
187
- workers: 1,
188
- ...(opts.rangeSec ? { projectSettings: { range: opts.rangeSec } } : {}),
189
- logProgress: opts.logProgress ?? false,
190
- ...(opts.onProgress
191
- ? { progressCallback: (_worker: number, progress: number) => opts.onProgress!(progress) }
192
- : {}),
193
- // Reuse the capture-managed Chrome-for-Testing when given (one browser
194
- // for both stages); else let revideo's puppeteer resolve its own.
195
- puppeteer: {
196
- // --password-store/--use-mock-keychain: never touch the OS keychain, so
197
- // macOS doesn't pop a "Chrome wants to use Chromium Safe Storage" prompt
198
- // mid-render (matches the capture launch in runtime/cdp.ts).
199
- args: [
200
- "--no-sandbox",
201
- "--disable-setuid-sandbox",
202
- "--password-store=basic",
203
- "--use-mock-keychain",
204
- ],
205
- ...(opts.chromePath ? { executablePath: opts.chromePath } : {}),
298
+ // 2. render headless, with cwd pinned to the scratch dir.
299
+ // revideo's @revideo/telemetry phones home to PostHog by default; this is an
300
+ // all-local tool, so default it OFF (an explicit user-set value still wins).
301
+ if (process.env.DISABLE_TELEMETRY === undefined) process.env.DISABLE_TELEMETRY = "true";
302
+ const prevCwd = process.cwd();
303
+ process.chdir(scratch);
304
+ let produced: string;
305
+ try {
306
+ produced = await renderVideo({
307
+ projectFile: "/src/scene/project.ts",
308
+ settings: {
309
+ outFile: "take.mp4",
310
+ outDir: RENDER_OUT,
311
+ workers: 1,
312
+ ...(opts.rangeSec ? { projectSettings: { range: opts.rangeSec } } : {}),
313
+ logProgress: opts.logProgress ?? false,
314
+ ...(opts.onProgress
315
+ ? {
316
+ progressCallback: (_worker: number, progress: number) => opts.onProgress!(progress),
317
+ }
318
+ : {}),
319
+ // vite's dev server refuses to serve outside its root, and its root is
320
+ // now a tmp dir — so allow the dependency tree the scene imports
321
+ // through the node_modules link (vite resolves it to the realpath).
322
+ viteConfig: {
323
+ server: { fs: { allow: [scratch, ...(deps ? [deps] : [])] } },
324
+ },
325
+ // Reuse the capture-managed Chrome-for-Testing for both stages.
326
+ puppeteer: {
327
+ // --password-store/--use-mock-keychain: never touch the OS keychain, so
328
+ // macOS doesn't pop a "Chrome wants to use Chromium Safe Storage" prompt
329
+ // mid-render (matches the capture launch in runtime/cdp.ts).
330
+ args: [
331
+ "--no-sandbox",
332
+ "--disable-setuid-sandbox",
333
+ "--password-store=basic",
334
+ "--use-mock-keychain",
335
+ ],
336
+ executablePath: opts.chromePath,
337
+ },
206
338
  },
207
- },
208
- });
209
- } finally {
210
- process.chdir(prevCwd);
211
- }
339
+ });
340
+ } finally {
341
+ process.chdir(prevCwd);
342
+ }
212
343
 
213
- // 4. deliver mp4 (motion-blur down from fps·samples if configured) + the
214
- // editable composition. OFF ⇒ a plain copy (byte-identical to before).
215
- await mkdir(dirname(resolve(opts.outPath)), { recursive: true });
216
- const producedAbs = resolve(PKG_ROOT, produced);
217
- if (motionBlurActive(composition.motionBlur)) {
218
- await motionBlurMp4(
219
- producedAbs,
220
- resolve(opts.outPath),
221
- composition.output.fps,
222
- composition.motionBlur.samples,
223
- composition.motionBlur.shutter,
224
- );
225
- } else {
226
- await copyFile(producedAbs, resolve(opts.outPath));
227
- }
228
- const compositionPath = resolve(opts.outPath).replace(/\.mp4$/i, "") + ".composition.json";
229
- if (opts.writeCompositionSibling !== false) {
230
- // strip the render-time review decoration — the editable artifact is the
231
- // clean composition, never the badged/watermarked variant of it.
232
- const { review: _review, ...persisted } = composition;
233
- await writeFile(compositionPath, JSON.stringify(persisted, null, 2));
234
- }
344
+ // 3. deliver mp4 (motion-blur down from fps·samples if configured) + the
345
+ // editable composition. OFF ⇒ a plain copy (byte-identical to before).
346
+ await mkdir(dirname(resolve(opts.outPath)), { recursive: true });
347
+ const producedAbs = resolve(scratch, produced);
348
+ if (motionBlurActive(composition.motionBlur)) {
349
+ await motionBlurMp4(
350
+ producedAbs,
351
+ resolve(opts.outPath),
352
+ composition.output.fps,
353
+ composition.motionBlur.samples,
354
+ composition.motionBlur.shutter,
355
+ );
356
+ } else {
357
+ await copyFile(producedAbs, resolve(opts.outPath));
358
+ }
359
+ const compositionPath = resolve(opts.outPath).replace(/\.mp4$/i, "") + ".composition.json";
360
+ if (opts.writeCompositionSibling !== false) {
361
+ // strip the render-time review decoration — the editable artifact is the
362
+ // clean composition, never the badged/watermarked variant of it.
363
+ const { review: _review, ...persisted } = composition;
364
+ await writeFile(compositionPath, JSON.stringify(persisted, null, 2));
365
+ }
235
366
 
236
- return { mp4Path: resolve(opts.outPath), compositionPath };
367
+ return { mp4Path: resolve(opts.outPath), compositionPath };
368
+ } finally {
369
+ await cleanupScratch(scratch);
370
+ }
237
371
  }
@@ -123,7 +123,7 @@ export default makeScene2D("take", function* (view) {
123
123
  spatial click point, so they get no ripple) */}
124
124
  {comp.events
125
125
  .filter((e) => e.kind !== "scroll" && e.kind !== "press")
126
- .map((e) => {
126
+ .map((e, index) => {
127
127
  const ms = comp.cursor.rippleMs / 1000;
128
128
  const prog = () => {
129
129
  const dt = t() - e.tMs / 1000;
@@ -131,6 +131,7 @@ export default makeScene2D("take", function* (view) {
131
131
  };
132
132
  return (
133
133
  <Circle
134
+ key={`${e.kind}-${e.tMs}-${index}`}
134
135
  position={[lx(e.point.x), ly(e.point.y)]}
135
136
  size={() => {
136
137
  const p = prog();
@@ -0,0 +1,14 @@
1
+ {
2
+ // The scene is NOT compiled by tsc (the package tsconfig excludes src/scene);
3
+ // it is compiled by revideo's vite at render time. But vite's dependency
4
+ // scanner still reads the nearest tsconfig to learn the JSX settings, and the
5
+ // repo base sets `jsx: react-jsx` with no import source (for apps/editor,
6
+ // which really is React). Without this override the scanner tries to resolve
7
+ // `react/jsx-dev-runtime` from scene.tsx, fails, and skips dependency
8
+ // pre-bundling with a scary warning on every render. The scene's JSX is
9
+ // revideo's, not React's.
10
+ "compilerOptions": {
11
+ "jsx": "react-jsx",
12
+ "jsxImportSource": "@revideo/2d/lib"
13
+ }
14
+ }