@open-take/compositor 0.1.1 → 0.2.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.
- package/dist/index.d.ts +154 -37
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +544 -218
- package/dist/index.js.map +1 -1
- package/package.json +8 -6
- package/src/camera.ts +324 -0
- package/src/ffmpeg.ts +27 -0
- package/src/index.ts +2 -1
- package/src/math.ts +158 -126
- package/src/plan.ts +102 -66
- package/src/presets.ts +10 -5
- package/src/render.ts +214 -80
- package/src/scene/scene.tsx +10 -17
- package/src/scene/tsconfig.json +14 -0
- package/src/types.ts +86 -23
- package/src/validate.ts +18 -0
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
|
|
7
|
-
//
|
|
8
|
-
// renderer forever; see
|
|
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 {
|
|
12
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
39
|
-
*
|
|
40
|
-
|
|
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) =>
|
|
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
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
339
|
+
});
|
|
340
|
+
} finally {
|
|
341
|
+
process.chdir(prevCwd);
|
|
342
|
+
}
|
|
212
343
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
367
|
+
return { mp4Path: resolve(opts.outPath), compositionPath };
|
|
368
|
+
} finally {
|
|
369
|
+
await cleanupScratch(scratch);
|
|
370
|
+
}
|
|
237
371
|
}
|
package/src/scene/scene.tsx
CHANGED
|
@@ -4,17 +4,11 @@
|
|
|
4
4
|
import { makeScene2D, Rect, Video, Line, Circle, Node, Gradient, Txt } from "@revideo/2d";
|
|
5
5
|
import { createSignal, tween, linear } from "@revideo/core";
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
stageCamera,
|
|
8
8
|
buildLegs,
|
|
9
9
|
cursorPos,
|
|
10
10
|
isDragging,
|
|
11
|
-
keyvalN,
|
|
12
|
-
keyvalP,
|
|
13
|
-
clampCenter,
|
|
14
|
-
stageEasing,
|
|
15
|
-
panEasing,
|
|
16
11
|
gradientEndpoints,
|
|
17
|
-
restStageScale,
|
|
18
12
|
smoother,
|
|
19
13
|
} from "../math";
|
|
20
14
|
import comp from "./.composition.json";
|
|
@@ -23,9 +17,9 @@ const vW = comp.source.videoWidth,
|
|
|
23
17
|
vH = comp.source.videoHeight;
|
|
24
18
|
const oW = comp.output.width,
|
|
25
19
|
oH = comp.output.height;
|
|
26
|
-
const
|
|
20
|
+
const cam = stageCamera(comp);
|
|
27
21
|
const legs = buildLegs(comp);
|
|
28
|
-
const rest =
|
|
22
|
+
const rest = cam.rest;
|
|
29
23
|
|
|
30
24
|
// video-px -> stage-local coords (stage local origin = video centre)
|
|
31
25
|
const lx = (px) => px - vW / 2;
|
|
@@ -46,12 +40,10 @@ const CURSOR = [
|
|
|
46
40
|
export default makeScene2D("take", function* (view) {
|
|
47
41
|
const t = createSignal(0);
|
|
48
42
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
const scaleAt = () => Math.max(rest, keyvalN(t(), stage.z, scaleEase, panEase));
|
|
54
|
-
const centerAt = () => clampCenter(keyvalP(t(), stage.c, panEase), scaleAt(), vW, vH, oW, oH);
|
|
43
|
+
// The camera: ONE eased viewport rect (centre + size in lockstep, targets
|
|
44
|
+
// pre-clamped at build) — see math.ts stageCamera. Mirrors the editor preview.
|
|
45
|
+
const scaleAt = () => cam.at(t()).scale;
|
|
46
|
+
const centerAt = () => cam.at(t()).center;
|
|
55
47
|
|
|
56
48
|
// Composition camera: ONE camera zooms the WHOLE
|
|
57
49
|
// composition (backdrop + the inset framed screen) together. At rest the
|
|
@@ -123,7 +115,7 @@ export default makeScene2D("take", function* (view) {
|
|
|
123
115
|
spatial click point, so they get no ripple) */}
|
|
124
116
|
{comp.events
|
|
125
117
|
.filter((e) => e.kind !== "scroll" && e.kind !== "press")
|
|
126
|
-
.map((e) => {
|
|
118
|
+
.map((e, index) => {
|
|
127
119
|
const ms = comp.cursor.rippleMs / 1000;
|
|
128
120
|
const prog = () => {
|
|
129
121
|
const dt = t() - e.tMs / 1000;
|
|
@@ -131,6 +123,7 @@ export default makeScene2D("take", function* (view) {
|
|
|
131
123
|
};
|
|
132
124
|
return (
|
|
133
125
|
<Circle
|
|
126
|
+
key={`${e.kind}-${e.tMs}-${index}`}
|
|
134
127
|
position={[lx(e.point.x), ly(e.point.y)]}
|
|
135
128
|
size={() => {
|
|
136
129
|
const p = prog();
|
|
@@ -227,5 +220,5 @@ export default makeScene2D("take", function* (view) {
|
|
|
227
220
|
if (review.label) view.add(pill(review.label, 1, true));
|
|
228
221
|
}
|
|
229
222
|
|
|
230
|
-
yield* tween(
|
|
223
|
+
yield* tween(cam.T, (v) => t(v * cam.T), linear);
|
|
231
224
|
});
|
|
@@ -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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -25,8 +25,22 @@ export type CaptureEventBase = {
|
|
|
25
25
|
/** selector / note, kept for editability */
|
|
26
26
|
sel?: string;
|
|
27
27
|
note?: string;
|
|
28
|
-
/** selective-zoom intent from the plan
|
|
28
|
+
/** selective-zoom intent from the plan. Absent/"auto" ⇒ the camera director
|
|
29
|
+
* decides from the ground-truth log; "always"/"never" hard-override it (and
|
|
30
|
+
* cut the beat out of any cluster — an override is a segment boundary). */
|
|
29
31
|
zoom?: ZoomIntent;
|
|
32
|
+
/** CAPTURE-DERIVED (frame-diff / mutation pass — the `effectBox` seam): the
|
|
33
|
+
* region that actually CHANGED after the action, viewport CSS px. The
|
|
34
|
+
* director frames THIS over `box` when present — a `type`'s result region, or
|
|
35
|
+
* a payoff that lands somewhere other than where you clicked. Absent ⇒ the
|
|
36
|
+
* director shapes an ROI from `box`/kind instead. */
|
|
37
|
+
effectBox?: BBox;
|
|
38
|
+
/** CAPTURE-DERIVED (same pass): fraction of the frame that changed after the
|
|
39
|
+
* action, 0..1. ≥ camera.pullOutCoverage ⇒ the action repainted most of the
|
|
40
|
+
* frame (nav / global restyle) ⇒ pull out to full view. Absent ⇒ the pull-out
|
|
41
|
+
* branch is skipped (the director can't tell nav from popover on bbox alone —
|
|
42
|
+
* it says so in the beat's `reason`). */
|
|
43
|
+
changeCoverage?: number;
|
|
30
44
|
};
|
|
31
45
|
|
|
32
46
|
/** A click (or a type's focus-click): an instantaneous action at a point. */
|
|
@@ -187,23 +201,27 @@ export type CursorConfig = {
|
|
|
187
201
|
rippleMs: number;
|
|
188
202
|
/** ms to hold a zoom after the action settles, before zooming back out */
|
|
189
203
|
holdMs: number;
|
|
190
|
-
/** ms for
|
|
204
|
+
/** ms for a zoom-OUT / pull-out ramp (to rest OR to any wider framing).
|
|
205
|
+
* Measured on a reference export: the pull-out is ~1.8× slower than the
|
|
206
|
+
* punch-in (their zoom-out spring ω≈5.2 rad/s ⇒ ~1340ms of critically-damped
|
|
207
|
+
* settle) — a slow, soft release reads premium; a fast one reads like a
|
|
208
|
+
* flinch. */
|
|
191
209
|
zoomOutMs: number;
|
|
192
210
|
/** ms for the zoom-IN ramp (into a target). Decoupled from travelMs so the
|
|
193
|
-
* zoom can be slower/gentler than the cursor
|
|
211
|
+
* zoom can be slower/gentler than the cursor. Measured reference punch-in spring
|
|
212
|
+
* ω≈9.4 rad/s ⇒ ~730ms. */
|
|
194
213
|
zoomInMs: number;
|
|
195
|
-
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
214
|
+
/** Optional cubic-bezier easing for the camera-rect ramps (centre + size in
|
|
215
|
+
* lockstep — see math.ts stageCamera). Absent ⇒ the default critically-
|
|
216
|
+
* damped spring (the measured reference curve). Set this only to force a
|
|
217
|
+
* bezier feel; `zoomSpring` wins over it when both are set. */
|
|
199
218
|
zoomEase?: [number, number, number, number];
|
|
200
|
-
/** Spring easing for the
|
|
201
|
-
* 0 = critically damped (
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* zoom-OUT (momentary backdrop dead-space) — keep it small for zoom. */
|
|
219
|
+
/** Spring easing for the camera-rect ramps, as a `bounce` amount ∈ [0,~0.6):
|
|
220
|
+
* 0 = critically damped (the measured reference zoom curve — also the
|
|
221
|
+
* default when neither zoomSpring nor zoomEase is set), higher = more
|
|
222
|
+
* overshoot/snap. The segment duration stays zoomInMs/zoomOutMs; bounce only
|
|
223
|
+
* shapes the curve. Bounce > 0 overshoots the RECT (a touch past the target
|
|
224
|
+
* frame, then settle) — keep it small. */
|
|
207
225
|
zoomSpring?: number;
|
|
208
226
|
/** ms to delay the synthetic cursor along a DRAG stroke, compensating for the
|
|
209
227
|
* capture pipeline latency: the captured ink appears ~this long after the pen
|
|
@@ -253,6 +271,53 @@ export type ReviewDecor = {
|
|
|
253
271
|
label?: string;
|
|
254
272
|
};
|
|
255
273
|
|
|
274
|
+
/** The auto-camera director's tuning. ON by default (a plan that specifies no
|
|
275
|
+
* zoom must still come out with sensible framing — the whole point). The
|
|
276
|
+
* numbers are the FEEL knobs; judge them by eye on a rendered clip, not on
|
|
277
|
+
* paper. `enabled: false` is the clean escape hatch: the director doesn't run
|
|
278
|
+
* and ONLY explicit `zoom: "always"/"never"` produce zoom (auto/absent hold
|
|
279
|
+
* full view). There is no legacy per-event heuristic to fall back to. */
|
|
280
|
+
export type CameraConfig = {
|
|
281
|
+
enabled: boolean;
|
|
282
|
+
/** an ROI fills this fraction of the frame when framed (bigger ⇒ tighter). */
|
|
283
|
+
fillFrac: number;
|
|
284
|
+
/** hard ceiling on scale. NOT the main lever — ROI SIZE drives the actual
|
|
285
|
+
* scale (a big type-ROI lands medium, a tiny icon lands tight); this just
|
|
286
|
+
* stops a pinpoint ROI zooming past legibility. */
|
|
287
|
+
maxScale: number;
|
|
288
|
+
/** a punch that can't stay on screen this long is dropped to full view (a
|
|
289
|
+
* sub-second punch reads as a flinch). Enforced AFTER hard breaks — the hold
|
|
290
|
+
* extends into the gap before the next break, never merges across one. */
|
|
291
|
+
minHoldMs: number;
|
|
292
|
+
/** a scale below this isn't worth a distinct frame: a single beat that fits
|
|
293
|
+
* under it holds full view, and a coalesced cluster whose UNION falls under
|
|
294
|
+
* it splits instead (the two are "different regions" → chain/pull, not hold). */
|
|
295
|
+
minZoomScale: number;
|
|
296
|
+
/** gap between two actions greater than this ⇒ they cannot share a frame. */
|
|
297
|
+
coalesceWindowMs: number;
|
|
298
|
+
/** two ROIs whose centres are closer than this (fraction of video width) MAY
|
|
299
|
+
* coalesce into one shared, held frame — the "cluster" (a thumbnail rail, a
|
|
300
|
+
* toolbar). Farther apart ⇒ a re-frame (progressive/deeper), not a hold. */
|
|
301
|
+
travelThreshold: number;
|
|
302
|
+
/** changeCoverage ≥ this ⇒ the action repainted most of the frame (nav /
|
|
303
|
+
* global) ⇒ pull out to full view. The nav-vs-popover divider — the one knob
|
|
304
|
+
* to tune by eye on real captures (frame-diff pass populates the input). */
|
|
305
|
+
pullOutCoverage: number;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Defaults tuned as a STARTING point — every number here is meant to be judged
|
|
309
|
+
// on a rendered clip and A/B'd one at a time, not signed off on paper.
|
|
310
|
+
export const DEFAULT_CAMERA: CameraConfig = {
|
|
311
|
+
enabled: true,
|
|
312
|
+
fillFrac: 0.6,
|
|
313
|
+
maxScale: 2.4,
|
|
314
|
+
minHoldMs: 1200,
|
|
315
|
+
minZoomScale: 1.25,
|
|
316
|
+
coalesceWindowMs: 900,
|
|
317
|
+
travelThreshold: 0.18,
|
|
318
|
+
pullOutCoverage: 0.55,
|
|
319
|
+
};
|
|
320
|
+
|
|
256
321
|
export type TakeComposition = {
|
|
257
322
|
output: { width: number; height: number; fps: number };
|
|
258
323
|
source: {
|
|
@@ -321,15 +386,13 @@ export const DEFAULT_CURSOR: CursorConfig = {
|
|
|
321
386
|
arcMax: 24,
|
|
322
387
|
rippleMs: 450,
|
|
323
388
|
holdMs: 1100,
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
// gives a soft landing into rest. Applied to scale + center together.
|
|
332
|
-
zoomEase: [0.3, 0.0, 0.2, 1.0],
|
|
389
|
+
// Camera ramp durations, measured off a reference export by frame-
|
|
390
|
+
// tracking (see math.ts springEase): punch-in spring ω≈9.4 rad/s ≈ 730ms,
|
|
391
|
+
// pull-out ω≈5.2 ≈ 1340ms. The slow soft release is half the premium feel.
|
|
392
|
+
// No zoomEase/zoomSpring set ⇒ stageEasing falls to springEase(0), the
|
|
393
|
+
// critically-damped spring that IS the measured SS curve over these windows.
|
|
394
|
+
zoomOutMs: 1340,
|
|
395
|
+
zoomInMs: 730,
|
|
333
396
|
// The captured ink trails the pen by the screencast/encode pipeline latency τ;
|
|
334
397
|
// delay the cursor by τ so its tip rides the ink front. Set to τ EXACTLY and
|
|
335
398
|
// the cursor locks to the ink at ALL stroke speeds (both are the same time-
|
package/src/validate.ts
CHANGED
|
@@ -187,6 +187,24 @@ export function validateComposition(
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
// camera-rect spring: bounce shapes the ONE ease driving centre+size, so an
|
|
191
|
+
// out-of-range value corrupts every camera move (see math.ts springEase).
|
|
192
|
+
const spring = comp.cursor.zoomSpring;
|
|
193
|
+
if (spring != null) {
|
|
194
|
+
if (!(spring >= 0 && spring < 0.6))
|
|
195
|
+
err(
|
|
196
|
+
"cursor.zoomSpring",
|
|
197
|
+
`zoomSpring ${spring} outside [0, 0.6)`,
|
|
198
|
+
"0 = critically damped (the default feel); keep bounce ≤ 0.3",
|
|
199
|
+
);
|
|
200
|
+
else if (spring > 0.3)
|
|
201
|
+
warn(
|
|
202
|
+
"cursor.zoomSpring",
|
|
203
|
+
`zoomSpring ${spring} is a lot of bounce — the rect overshoot can flash backdrop at edge-flush targets and over-zoom tight punches`,
|
|
204
|
+
"keep ≤ 0.3 (the editor slider's max) unless the flash is wanted",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
190
208
|
// tail: the composition must outlast the last action (+ a little settle)
|
|
191
209
|
if (comp.durationMs < lastEnd)
|
|
192
210
|
err(
|