@open-take/compositor 0.1.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/src/presets.ts ADDED
@@ -0,0 +1,147 @@
1
+ // Curated preset vocabulary — the Screen-Studio move (named levels, not raw
2
+ // numbers) applied to the conversational refine loop. The composition schema
3
+ // stays raw numbers; presets are a dictionary the CLI/agent speak: the agent
4
+ // writes numbers into composition.json, and `beats`/`ab` reverse-map numbers
5
+ // back to names for display. A value matching no preset displays as "custom"
6
+ // and must never be silently rounded to a named level (bbox-derived precision
7
+ // is ground truth).
8
+
9
+ import { DEFAULT_MOTION_BLUR } from "./types";
10
+ import type { CursorConfig, FramingConfig, MotionBlurConfig } from "./types";
11
+
12
+ // --- zoom levels (absolute stage scale; rest ≈ 0.92 at same-size output) ----
13
+ // medium matches the planner's default cap (1.5), so agent-planned zooms land
14
+ // on a named level; close stays under the validator's 2.5 soft cap.
15
+ export const ZOOM_LEVELS = {
16
+ light: 1.25,
17
+ medium: 1.5,
18
+ tight: 1.8,
19
+ close: 2.2,
20
+ } as const;
21
+ export type ZoomLevelName = keyof typeof ZOOM_LEVELS;
22
+
23
+ /** Name a scale if it sits within tolerance of a preset; else null (custom). */
24
+ export function zoomLevelName(scale: number, tol = 0.07): ZoomLevelName | null {
25
+ let best: ZoomLevelName | null = null;
26
+ let bestD = tol;
27
+ for (const [name, v] of Object.entries(ZOOM_LEVELS) as [ZoomLevelName, number][]) {
28
+ const d = Math.abs(scale - v);
29
+ if (d <= bestD) {
30
+ best = name;
31
+ bestD = d;
32
+ }
33
+ }
34
+ return best;
35
+ }
36
+
37
+ // --- motion (pace) bundles ---------------------------------------------------
38
+ // One name moves the whole feel together: cursor speed + hold + zoom ramps.
39
+ // "natural" IS the shipped default (DEFAULT_CURSOR) — naming it makes the
40
+ // default state speakable.
41
+ export type MotionPreset = Pick<
42
+ CursorConfig,
43
+ "travelWidthsPerSec" | "holdMs" | "zoomInMs" | "zoomOutMs"
44
+ >;
45
+ export const MOTION: Record<"calm" | "natural" | "brisk", MotionPreset> = {
46
+ calm: { travelWidthsPerSec: 0.28, holdMs: 1500, zoomInMs: 950, zoomOutMs: 950 },
47
+ natural: { travelWidthsPerSec: 0.35, holdMs: 1100, zoomInMs: 760, zoomOutMs: 800 },
48
+ brisk: { travelWidthsPerSec: 0.45, holdMs: 750, zoomInMs: 560, zoomOutMs: 600 },
49
+ };
50
+ export type MotionName = keyof typeof MOTION;
51
+
52
+ export function motionName(cursor: CursorConfig): MotionName | null {
53
+ for (const [name, m] of Object.entries(MOTION) as [MotionName, MotionPreset][]) {
54
+ if (
55
+ Math.abs(cursor.travelWidthsPerSec - m.travelWidthsPerSec) < 0.015 &&
56
+ Math.abs(cursor.holdMs - m.holdMs) < 60 &&
57
+ Math.abs(cursor.zoomInMs - m.zoomInMs) < 60 &&
58
+ Math.abs(cursor.zoomOutMs - m.zoomOutMs) < 60
59
+ )
60
+ return name;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ // --- looks (backdrop bundles) ------------------------------------------------
66
+ // A look is the WHOLE backdrop treatment as one coherent bundle — background
67
+ // gradient + corner radius + shadow — so "paper" doesn't produce a light
68
+ // backdrop with dark-tuned corners/shadow.
69
+ export type LookPreset = Pick<FramingConfig, "background" | "cornerRadius" | "shadow">;
70
+ const DARK_SHADOW: LookPreset["shadow"] = {
71
+ color: "rgba(0,0,0,0.55)",
72
+ blur: 60,
73
+ offset: { x: 0, y: 28 },
74
+ };
75
+ export const LOOKS: Record<string, LookPreset> = {
76
+ midnight: {
77
+ background: { from: "#1e1b3a", to: "#0a0e1c" },
78
+ cornerRadius: 28,
79
+ shadow: DARK_SHADOW,
80
+ },
81
+ ink: {
82
+ background: { from: "#16181d", to: "#07080a" },
83
+ cornerRadius: 28,
84
+ shadow: DARK_SHADOW,
85
+ },
86
+ slate: {
87
+ background: { from: "#232733", to: "#0e1116" },
88
+ cornerRadius: 28,
89
+ shadow: DARK_SHADOW,
90
+ },
91
+ ocean: {
92
+ background: { from: "#0c2b36", to: "#071019" },
93
+ cornerRadius: 28,
94
+ shadow: DARK_SHADOW,
95
+ },
96
+ plum: {
97
+ background: { from: "#2a1e3a", to: "#140a1c" },
98
+ cornerRadius: 28,
99
+ shadow: DARK_SHADOW,
100
+ },
101
+ ember: {
102
+ background: { from: "#38231d", to: "#1a0d09" },
103
+ cornerRadius: 28,
104
+ shadow: DARK_SHADOW,
105
+ },
106
+ paper: {
107
+ background: { from: "#f6f4ef", to: "#ddd8cd" },
108
+ cornerRadius: 22,
109
+ shadow: { color: "rgba(31,27,20,0.30)", blur: 45, offset: { x: 0, y: 20 } },
110
+ },
111
+ plain: {
112
+ background: { from: "#101014", to: "#101014", type: "solid" },
113
+ cornerRadius: 28,
114
+ shadow: DARK_SHADOW,
115
+ },
116
+ };
117
+ export type LookName = keyof typeof LOOKS;
118
+
119
+ export function lookName(framing: FramingConfig): string | null {
120
+ const bg = framing.background;
121
+ for (const [name, l] of Object.entries(LOOKS)) {
122
+ if (
123
+ l.background.from.toLowerCase() === bg.from.toLowerCase() &&
124
+ l.background.to.toLowerCase() === bg.to.toLowerCase() &&
125
+ (l.background.type ?? "gradient") === (bg.type ?? "gradient")
126
+ )
127
+ return name;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ // --- finish (motion blur) ----------------------------------------------------
133
+ export const FINISH: Record<"smooth" | "crisp" | "heavy", MotionBlurConfig | undefined> = {
134
+ smooth: DEFAULT_MOTION_BLUR, // {samples: 6, shutter: 0.7}
135
+ crisp: undefined, // blur off — exports ~6× faster
136
+ heavy: { samples: 8, shutter: 0.85 },
137
+ };
138
+ export type FinishName = keyof typeof FINISH;
139
+
140
+ export function finishName(mb: MotionBlurConfig | undefined): FinishName | null {
141
+ const active = !!mb && mb.samples > 1 && mb.shutter > 0;
142
+ if (!active) return "crisp";
143
+ for (const [name, f] of Object.entries(FINISH) as [FinishName, MotionBlurConfig | undefined][]) {
144
+ if (f && mb && f.samples === mb.samples && Math.abs(f.shutter - mb.shutter) < 0.03) return name;
145
+ }
146
+ return null;
147
+ }
package/src/render.ts ADDED
@@ -0,0 +1,237 @@
1
+ // renderTake: composition (or capture log) + captured video -> polished
2
+ // mp4 + the editable composition written alongside it.
3
+ //
4
+ // Runs revideo headless (vite + chromium + ffmpeg). The renderer resolves
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).
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { copyFile, mkdir, writeFile } from "node:fs/promises";
12
+ import { dirname, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { renderVideo } from "@revideo/renderer";
15
+ import { resolveFfmpeg } from "./ffmpeg";
16
+ import { type PlanOpts, planComposition } from "./plan";
17
+ import { type CaptureLog, type TakeComposition, motionBlurActive } from "./types";
18
+ import { type CompositionIssue, formatIssues, validateComposition } from "./validate";
19
+
20
+ // dist/index.js -> package root
21
+ 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)
25
+
26
+ export type RenderTakeOpts = {
27
+ /** input capture video (webm or mp4) */
28
+ videoPath: string;
29
+ /** output polished mp4 path */
30
+ outPath: string;
31
+ /** provide a capture log (auto-planned) ... */
32
+ log?: CaptureLog;
33
+ /** ... or a ready-made composition (editable artifact) */
34
+ composition?: TakeComposition;
35
+ planOpts?: PlanOpts;
36
+ logProgress?: boolean;
37
+ /** 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;
42
+ /** the capture log, for cross-checking that an edited composition didn't
43
+ * drift an action's capture-locked tMs (see validateComposition). Optional —
44
+ * the structural checks run regardless. */
45
+ captureLog?: CaptureLog;
46
+ /** skip the pre-render structural validation. Default false — we validate and
47
+ * refuse to render an errored composition (a render is expensive; catch a bad
48
+ * hand-edit in milliseconds instead). */
49
+ skipValidate?: boolean;
50
+ /** progress callback (0..1) forwarded from revideo's renderer. */
51
+ onProgress?: (progress: number) => void;
52
+ /** render only this window of the composition timeline, in SECONDS — the
53
+ * windowed-render path behind A/B variant reels (a 4s window instead of the
54
+ * whole take). With motion blur OFF, frames are identical to the same span
55
+ * of a full render (the timeline is deterministic). With blur active the
56
+ * content matches but not bit-exactly: the tmix shutter windows are phased
57
+ * from the CLIP start, and the first frame's trailing window is truncated.
58
+ * Forwarded to revideo's projectSettings.range. */
59
+ rangeSec?: [number, number];
60
+ /** write the editable `<out>.composition.json` sibling (default true). Review
61
+ * copies and A/B reels are disposable — they skip the sibling. */
62
+ writeCompositionSibling?: boolean;
63
+ };
64
+
65
+ function run(cmd: string, args: string[]): Promise<void> {
66
+ return new Promise((res, rej) => {
67
+ const c = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
68
+ let err = "";
69
+ c.stderr.on("data", (d) => (err += d));
70
+ c.on("error", rej);
71
+ c.on("close", (code) => (code === 0 ? res() : rej(new Error(`${cmd} exited ${code}: ${err}`))));
72
+ });
73
+ }
74
+
75
+ /** Normalise the capture to a constant-fps mp4 the web decoder can read.
76
+ * fps follows the composition so a hi-fps capture can render at 60 (the
77
+ * render grid must match — a 30-grid would throw away the extra frames). */
78
+ async function toMp4(videoPath: string, outMp4: string, fps: number): Promise<void> {
79
+ await mkdir(dirname(outMp4), { recursive: true });
80
+ await run(await resolveFfmpeg(), [
81
+ "-y",
82
+ "-loglevel",
83
+ "error",
84
+ "-i",
85
+ resolve(videoPath),
86
+ "-c:v",
87
+ "libx264",
88
+ "-pix_fmt",
89
+ "yuv420p",
90
+ "-crf",
91
+ "18",
92
+ "-r",
93
+ String(fps),
94
+ "-an",
95
+ outMp4,
96
+ ]);
97
+ }
98
+
99
+ /** Temporal-supersampling motion blur: the scene was rendered at fps·samples
100
+ * (project.ts); average a trailing shutter window of sub-frames back down to the
101
+ * output fps. `tmix=frames=M` averages M consecutive sub-frames; `fps=baseFps`
102
+ * then decimates ≈every `samples`-th, so each output frame = the mean of the last
103
+ * M sub-frames of its interval (a trailing shutter). Re-tags bt709/tv to match
104
+ * the capture pipeline (the input is already bt709, but tmix→encode must keep it). */
105
+ async function motionBlurMp4(
106
+ inMp4: string,
107
+ outMp4: string,
108
+ baseFps: number,
109
+ samples: number,
110
+ shutter: number,
111
+ ): Promise<void> {
112
+ const M = Math.max(1, Math.min(samples, Math.round(shutter * samples)));
113
+ const vf =
114
+ `tmix=frames=${M},fps=${baseFps},format=yuv420p,` +
115
+ "setparams=range=tv:colorspace=bt709:color_primaries=bt709:color_trc=bt709";
116
+ await run(await resolveFfmpeg(), [
117
+ "-y",
118
+ "-loglevel",
119
+ "error",
120
+ "-i",
121
+ resolve(inMp4),
122
+ "-vf",
123
+ vf,
124
+ "-c:v",
125
+ "libx264",
126
+ "-pix_fmt",
127
+ "yuv420p",
128
+ "-crf",
129
+ "18",
130
+ "-r",
131
+ String(baseFps),
132
+ "-an",
133
+ outMp4,
134
+ ]);
135
+ }
136
+
137
+ export async function renderTake(
138
+ opts: RenderTakeOpts,
139
+ ): Promise<{ mp4Path: string; compositionPath: string }> {
140
+ const composition: TakeComposition =
141
+ opts.composition ??
142
+ planComposition(
143
+ opts.log ??
144
+ (() => {
145
+ throw new Error("renderTake: provide `log` or `composition`");
146
+ })(),
147
+ opts.planOpts,
148
+ );
149
+
150
+ // 0. validate BEFORE the expensive render. A hand-edited composition (the
151
+ // refine loop) can carry a malformed zoom or a capture-locked tMs drift;
152
+ // catch it in milliseconds rather than after a multi-second render.
153
+ if (!opts.skipValidate) {
154
+ const issues: CompositionIssue[] = validateComposition(composition, {
155
+ captureLog: opts.captureLog ?? opts.log,
156
+ });
157
+ const errors = issues.filter((i) => i.severity === "error");
158
+ const warns = issues.filter((i) => i.severity === "warn");
159
+ if (opts.logProgress && warns.length)
160
+ process.stderr.write(`composition warnings:\n${formatIssues(warns)}\n`);
161
+ if (errors.length)
162
+ throw new Error(
163
+ `composition has ${errors.length} error(s) — refusing to render:\n${formatIssues(errors)}`,
164
+ );
165
+ }
166
+
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));
173
+
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;
181
+ 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 } : {}),
206
+ },
207
+ },
208
+ });
209
+ } finally {
210
+ process.chdir(prevCwd);
211
+ }
212
+
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
+ }
235
+
236
+ return { mp4Path: resolve(opts.outPath), compositionPath };
237
+ }
@@ -0,0 +1,19 @@
1
+ // Excluded from tsc (src/scene/**); compiled by revideo's vite at render.
2
+ import { makeProject } from "@revideo/core";
3
+ import scene from "./scene";
4
+ import comp from "./.composition.json";
5
+
6
+ // Motion blur = temporal supersampling: render at fps·samples and let render.ts
7
+ // average sub-frames back down to the output fps (ffmpeg tmix). Off (samples ≤ 1
8
+ // or shutter 0) ⇒ just the output fps, i.e. unchanged.
9
+ const mb = comp.motionBlur;
10
+ const subSamples = mb && mb.samples > 1 && mb.shutter > 0 ? mb.samples : 1;
11
+
12
+ export default makeProject({
13
+ scenes: [scene],
14
+ settings: {
15
+ shared: { size: { x: comp.output.width, y: comp.output.height } },
16
+ // honour the composition's fps (default 30) — a hi-fps capture renders at 60
17
+ rendering: { fps: comp.output.fps * subSamples },
18
+ },
19
+ });
@@ -0,0 +1,231 @@
1
+ // Generic revideo scene: renders ANY TakeComposition. Compiled by
2
+ // revideo's vite at render time (NOT typechecked by tsc — excluded).
3
+ // renderTake writes ./.composition.json before each render.
4
+ import { makeScene2D, Rect, Video, Line, Circle, Node, Gradient, Txt } from "@revideo/2d";
5
+ import { createSignal, tween, linear } from "@revideo/core";
6
+ import {
7
+ buildStageKeyframes,
8
+ buildLegs,
9
+ cursorPos,
10
+ isDragging,
11
+ keyvalN,
12
+ keyvalP,
13
+ clampCenter,
14
+ stageEasing,
15
+ panEasing,
16
+ gradientEndpoints,
17
+ restStageScale,
18
+ smoother,
19
+ } from "../math";
20
+ import comp from "./.composition.json";
21
+
22
+ const vW = comp.source.videoWidth,
23
+ vH = comp.source.videoHeight;
24
+ const oW = comp.output.width,
25
+ oH = comp.output.height;
26
+ const stage = buildStageKeyframes(comp);
27
+ const legs = buildLegs(comp);
28
+ const rest = restStageScale(vW, vH, oW, oH, comp.framing.insetFrac);
29
+
30
+ // video-px -> stage-local coords (stage local origin = video centre)
31
+ const lx = (px) => px - vW / 2;
32
+ const ly = (py) => py - vH / 2;
33
+
34
+ // arrow cursor (tip at local 0,0), scaled
35
+ const S = comp.cursor.scale;
36
+ const CURSOR = [
37
+ [0, 0],
38
+ [0, 17],
39
+ [4.5, 13],
40
+ [7.5, 19.5],
41
+ [10, 18.3],
42
+ [7, 11.7],
43
+ [12, 11.7],
44
+ ].map(([x, y]) => [x * S, y * S]);
45
+
46
+ export default makeScene2D("take", function* (view) {
47
+ const t = createSignal(0);
48
+
49
+ // zoom/pan stage easing (scale + center in unison): spring → bezier → smoother
50
+ const scaleEase = stageEasing(comp.cursor); // zoom-IN: spring allowed
51
+ const panEase = panEasing(comp.cursor); // zoom-OUT scale + centre: smooth bezier
52
+ // spring in / bezier out (smooth settle); Math.max(rest,…) is a floor. Mirrors derive.ts.
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);
55
+
56
+ // Composition camera: ONE camera zooms the WHOLE
57
+ // composition (backdrop + the inset framed screen) together. At rest the
58
+ // camera shows the full composition (backdrop margin around the inset screen);
59
+ // a zoom crops into the screen (so it fills the frame — backdrop cropped out).
60
+ // Because the backdrop scales/pans WITH everything (it's inside the camera, not
61
+ // a static layer the video overfills), zoom-OUT is one uniform motion field —
62
+ // the backdrop slides back in at the edges with no static-edge *reveal*, which
63
+ // is what removes the old two-stage zoom-out stutter.
64
+ //
65
+ // Geometry: cameraNode scale = s/rest, position = -(c-vW/2)·s. Inside it the
66
+ // screen group is drawn at scale `rest` (the inset), so the video's NET scale
67
+ // is (s/rest)·rest = s and its NET position is s·(px−c) — identical to before,
68
+ // i.e. the video/cursor render exactly as they did; only the backdrop moved
69
+ // inside the camera. The backdrop Rect fills the composition (oW×oH); at rest
70
+ // (s=rest ⇒ camera scale 1, centred) it covers the output exactly.
71
+ const fr = comp.framing;
72
+ const bg = comp.framing.background;
73
+ const ge = gradientEndpoints(bg.angle, oW, oH);
74
+ view.add(
75
+ <Node
76
+ position={() => {
77
+ const s = scaleAt(),
78
+ c = centerAt();
79
+ return [-(c.x - vW / 2) * s, -(c.y - vH / 2) * s];
80
+ }}
81
+ scale={() => {
82
+ const z = scaleAt() / rest;
83
+ return [z, z];
84
+ }}
85
+ >
86
+ {/* backdrop — part of the composition, zoomed by the camera */}
87
+ <Rect
88
+ width={oW}
89
+ height={oH}
90
+ fill={
91
+ bg.type === "solid"
92
+ ? bg.from
93
+ : new Gradient({
94
+ type: "linear",
95
+ from: [ge.x0 - oW / 2, ge.y0 - oH / 2],
96
+ to: [ge.x1 - oW / 2, ge.y1 - oH / 2],
97
+ stops: [
98
+ { offset: 0, color: bg.from },
99
+ { offset: 1, color: bg.to },
100
+ ],
101
+ })
102
+ }
103
+ />
104
+
105
+ {/* screen group: the inset framed recording (scale `rest`), so the video
106
+ fills the composition's content area minus the backdrop margin. */}
107
+ <Node scale={[rest, rest]}>
108
+ {/* framing rendered IN revideo: rounded mask + drop shadow */}
109
+ <Rect
110
+ width={vW}
111
+ height={vH}
112
+ radius={fr.cornerRadius}
113
+ clip
114
+ fill={"#0a0e1c"}
115
+ shadowColor={fr.shadow.color}
116
+ shadowBlur={fr.shadow.blur}
117
+ shadowOffset={[fr.shadow.offset.x, fr.shadow.offset.y]}
118
+ >
119
+ <Video src={comp.source.videoUrl} width={vW} height={vH} play={true} />
120
+ </Rect>
121
+
122
+ {/* click ripples — pointer-landing beats only (scroll/press have no
123
+ spatial click point, so they get no ripple) */}
124
+ {comp.events
125
+ .filter((e) => e.kind !== "scroll" && e.kind !== "press")
126
+ .map((e) => {
127
+ const ms = comp.cursor.rippleMs / 1000;
128
+ const prog = () => {
129
+ const dt = t() - e.tMs / 1000;
130
+ return dt >= 0 && dt <= ms ? dt / ms : -1;
131
+ };
132
+ return (
133
+ <Circle
134
+ position={[lx(e.point.x), ly(e.point.y)]}
135
+ size={() => {
136
+ const p = prog();
137
+ return p < 0 ? 0 : (12 + 60 * smoother(p)) * 2;
138
+ }}
139
+ stroke={"white"}
140
+ lineWidth={4}
141
+ opacity={() => {
142
+ const p = prog();
143
+ return p < 0 ? 0 : (150 * (1 - p)) / 255;
144
+ }}
145
+ />
146
+ );
147
+ })}
148
+
149
+ {/* synthetic cursor on ground-truth waypoints */}
150
+ <Node
151
+ position={() => {
152
+ const c = cursorPos(t(), legs, comp);
153
+ return [lx(c.x), ly(c.y)];
154
+ }}
155
+ >
156
+ {/* pressed-state ring while a drag is mid-stroke (button held) */}
157
+ <Circle
158
+ size={() => (isDragging(t(), legs) ? 30 : 0)}
159
+ fill={"rgba(255,255,255,0.16)"}
160
+ stroke={"white"}
161
+ lineWidth={2}
162
+ />
163
+ <Line
164
+ points={CURSOR.map(([x, y]) => [x + 2.5, y + 2.5])}
165
+ closed
166
+ fill={"rgba(0,0,0,0.35)"}
167
+ />
168
+ <Line points={CURSOR} closed fill={"rgb(20,20,24)"} stroke={"white"} lineWidth={2} />
169
+ </Node>
170
+ </Node>
171
+ </Node>,
172
+ );
173
+
174
+ // Review decoration (badges / watermark / variant label) — SCREEN space,
175
+ // added AFTER the camera node so it draws on top and never rides the zoom.
176
+ // Only present on review copies and A/B reels (composition.review), never on
177
+ // the postable master. Text renders in Chrome, so no ffmpeg font filters.
178
+ const review = comp.review;
179
+ if (review) {
180
+ const k = oH / 1080; // scale UI with output size
181
+ const FONT = "system-ui, -apple-system, 'Segoe UI', 'Noto Sans', sans-serif";
182
+ const pad = 36 * k;
183
+ if (review.watermark) {
184
+ view.add(
185
+ <Txt
186
+ text={review.watermark}
187
+ position={[oW / 2 - pad, -oH / 2 + pad]}
188
+ offset={[1, -1]}
189
+ fontFamily={FONT}
190
+ fontSize={24 * k}
191
+ letterSpacing={4 * k}
192
+ fontWeight={600}
193
+ fill={"rgba(255,255,255,0.34)"}
194
+ />,
195
+ );
196
+ }
197
+ const pill = (text, opacity, big) => (
198
+ <Rect
199
+ layout
200
+ padding={[10 * k, 16 * k]}
201
+ radius={8 * k}
202
+ fill={"rgba(0,0,0,0.55)"}
203
+ position={[-oW / 2 + pad, oH / 2 - pad]}
204
+ offset={[-1, 1]}
205
+ opacity={opacity}
206
+ >
207
+ <Txt
208
+ text={text}
209
+ fontFamily={FONT}
210
+ fontSize={(big ? 30 : 25) * k}
211
+ fontWeight={500}
212
+ fill={"rgba(255,255,255,0.94)"}
213
+ />
214
+ </Rect>
215
+ );
216
+ for (const b of review.badges ?? []) {
217
+ // 120ms fade at each end so badge swaps don't pop
218
+ const op = () => {
219
+ const ms = t() * 1000;
220
+ if (ms < b.fromMs || ms >= b.toMs) return 0;
221
+ const inF = Math.min(1, (ms - b.fromMs) / 120);
222
+ const outF = Math.min(1, (b.toMs - ms) / 120);
223
+ return Math.min(inF, outF);
224
+ };
225
+ view.add(pill(b.text, op, false));
226
+ }
227
+ if (review.label) view.add(pill(review.label, 1, true));
228
+ }
229
+
230
+ yield* tween(stage.T, (v) => t(v * stage.T), linear);
231
+ });