creator-editing-studio 1.3.5 → 1.3.7

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.
@@ -1,153 +1,153 @@
1
- import React from 'react';
2
- import {useCurrentFrame, useVideoConfig} from 'remotion';
3
- import {EASE} from '../design/motion';
4
- import {COLOR, FONT, RADIUS, WEIGHT} from '../design/tokens';
5
- import {durFrames, perFrame, progress, travelFrames} from '../lib/animate';
6
- import {Presence} from './Presence';
7
- import {VelocityBlur} from './VelocityBlur';
8
-
9
- /** A point the cursor ARRIVES at by frame `at` (the tip of the arrow). */
10
- export type CursorStop = {at: number; x: number; y: number; click?: boolean};
11
-
12
- const CLICK_FRAMES = 6;
13
- /** Pointer size and where its tip sits inside the 64px artwork. */
14
- const POINTER = 68;
15
- const TIP = 6 * (POINTER / 64);
16
-
17
- /**
18
- * An arrow cursor that guides the eye: travels between stops on gentle arcs,
19
- * arriving exactly on time, and clicks with a press + ripple.
20
- */
21
- export const Cursor: React.FC<{
22
- stops: CursorStop[];
23
- hideAt: number;
24
- /**
25
- * A name tag riding behind the arrow, the way a collaborator's cursor is labelled in Figma.
26
- * Optional and additive: every existing use renders exactly as before without it.
27
- *
28
- * Pass a function of the frame to change the word mid-move — "copy" becoming "copied" the
29
- * instant the click lands reads as the action succeeding, with no second element needed.
30
- */
31
- label?: string | ((frame: number) => string);
32
- /**
33
- * Arrow colour. Defaults to the brand accent, which is what every earlier use gets.
34
- *
35
- * The setup walkthrough passes ink instead, asked for directly ("the arrow should be black"):
36
- * over a white email and a light desktop a accent arrow reads as a graphic sitting on the shot,
37
- * where a black one reads as the viewer's own pointer.
38
- */
39
- fill?: string;
40
- /** Tag colour, as a function of the frame where it changes state mid-move. */
41
- tagColour?: string | ((frame: number) => string);
42
- /** Tag text colour, for tags too dark for ink to read on. */
43
- tagText?: string | ((frame: number) => string);
44
- }> = ({
45
- stops,
46
- hideAt,
47
- label,
48
- fill = COLOR.accent,
49
- tagColour = COLOR.accent,
50
- tagText = COLOR.fg,
51
- }) => {
52
- const frame = useCurrentFrame();
53
- const {fps} = useVideoConfig();
54
-
55
- const positionAt = (f: number) => {
56
- let x = stops[0].x;
57
- let y = stops[0].y;
58
- for (let i = 1; i < stops.length; i++) {
59
- const from = stops[i - 1];
60
- const to = stops[i];
61
- const distance = Math.hypot(to.x - from.x, to.y - from.y);
62
- const travel = Math.min(travelFrames(distance, fps), Math.max(1, to.at - from.at));
63
- const depart = to.at - travel;
64
- if (f <= depart) break;
65
- const t = progress(f, depart, travel, EASE.inOut);
66
- // Arc: bulge perpendicular to the path, peaking mid-way.
67
- const bulge = Math.sin(Math.PI * t) * distance * 0.1;
68
- const nx = distance ? -(to.y - from.y) / distance : 0;
69
- const ny = distance ? (to.x - from.x) / distance : 0;
70
- x = from.x + (to.x - from.x) * t + nx * bulge;
71
- y = from.y + (to.y - from.y) * t + ny * bulge;
72
- }
73
- return {x, y};
74
- };
75
-
76
- const {x, y} = positionAt(frame);
77
- const click = stops.find((s) => s.click && frame >= s.at && frame < s.at + durFrames('slow', fps));
78
- const pressT = click ? progress(frame, click.at, CLICK_FRAMES, EASE.inOut) : 0;
79
- const press = 1 - 0.14 * Math.sin(Math.PI * pressT);
80
- const ripple = click ? progress(frame, click.at, durFrames('slow', fps)) : 0;
81
-
82
- // Resolved first so a label that returns an empty string removes the tag outright, which is
83
- // how "copied" disappears once the line has been pasted.
84
- const labelText = typeof label === 'function' ? label(frame) : label;
85
-
86
- return (
87
- <Presence at={stops[0].at} until={hideAt} dur="fast" from="none" style={{position: 'absolute', inset: 0}}>
88
- {click ? (
89
- <div
90
- style={{
91
- position: 'absolute',
92
- left: click.x - 70 * ripple,
93
- top: click.y - 70 * ripple,
94
- width: 140 * ripple,
95
- height: 140 * ripple,
96
- borderRadius: RADIUS.pill,
97
- border: `4px solid ${fill}`,
98
- opacity: 1 - ripple,
99
- }}
100
- />
101
- ) : null}
102
- <VelocityBlur
103
- vx={perFrame((f) => positionAt(f).x, frame)}
104
- vy={perFrame((f) => positionAt(f).y, frame)}
105
- style={{
106
- position: 'absolute',
107
- left: x - TIP,
108
- top: y - TIP,
109
- transformOrigin: `${TIP}px ${TIP}px`,
110
- transform: `scale(${press.toFixed(4)})`,
111
- filter: `drop-shadow(0 8px 14px ${COLOR.fg}66)`,
112
- }}
113
- >
114
- {/* Solid pointer (as in the reference): tip top-left, notched tail. */}
115
- <svg width={POINTER} height={POINTER} viewBox="0 0 64 64">
116
- <path
117
- d="M6 6 L58 26 Q60 27 58 28 L35 35 L28 58 Q27 60 26 58 Z"
118
- fill={fill}
119
- stroke={COLOR.fg}
120
- strokeWidth={3}
121
- strokeLinejoin="round"
122
- />
123
- </svg>
124
- </VelocityBlur>
125
-
126
- {labelText ? (
127
- <div
128
- style={{
129
- position: 'absolute',
130
- // Sits behind and below the tip, where a Figma cursor label sits, so it never
131
- // covers the thing being pointed at.
132
- left: x + POINTER * 0.42,
133
- top: y + POINTER * 0.52,
134
- display: 'flex',
135
- alignItems: 'center',
136
- padding: '7px 16px',
137
- borderRadius: RADIUS.pill,
138
- backgroundColor: typeof tagColour === 'function' ? tagColour(frame) : tagColour,
139
- fontFamily: FONT.sans,
140
- fontSize: 26,
141
- fontWeight: WEIGHT.semibold,
142
- letterSpacing: '-0.015em',
143
- color: typeof tagText === 'function' ? tagText(frame) : tagText,
144
- whiteSpace: 'nowrap',
145
- boxShadow: `0 6px 16px ${COLOR.fg}33`,
146
- }}
147
- >
148
- {labelText}
149
- </div>
150
- ) : null}
151
- </Presence>
152
- );
153
- };
1
+ import React from 'react';
2
+ import {useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {EASE} from '../design/motion';
4
+ import {COLOR, FONT, RADIUS, WEIGHT} from '../design/tokens';
5
+ import {durFrames, perFrame, progress, travelFrames} from '../lib/animate';
6
+ import {Presence} from './Presence';
7
+ import {VelocityBlur} from './VelocityBlur';
8
+
9
+ /** A point the cursor ARRIVES at by frame `at` (the tip of the arrow). */
10
+ export type CursorStop = {at: number; x: number; y: number; click?: boolean};
11
+
12
+ const CLICK_FRAMES = 6;
13
+ /** Pointer size and where its tip sits inside the 64px artwork. */
14
+ const POINTER = 68;
15
+ const TIP = 6 * (POINTER / 64);
16
+
17
+ /**
18
+ * An arrow cursor that guides the eye: travels between stops on gentle arcs,
19
+ * arriving exactly on time, and clicks with a press + ripple.
20
+ */
21
+ export const Cursor: React.FC<{
22
+ stops: CursorStop[];
23
+ hideAt: number;
24
+ /**
25
+ * A name tag riding behind the arrow, the way a collaborator's cursor is labelled in Figma.
26
+ * Optional and additive: every existing use renders exactly as before without it.
27
+ *
28
+ * Pass a function of the frame to change the word mid-move — "copy" becoming "copied" the
29
+ * instant the click lands reads as the action succeeding, with no second element needed.
30
+ */
31
+ label?: string | ((frame: number) => string);
32
+ /**
33
+ * Arrow colour. Defaults to the brand accent, which is what every earlier use gets.
34
+ *
35
+ * The setup walkthrough passes ink instead, asked for directly ("the arrow should be black"):
36
+ * over a white email and a light desktop a accent arrow reads as a graphic sitting on the shot,
37
+ * where a black one reads as the viewer's own pointer.
38
+ */
39
+ fill?: string;
40
+ /** Tag colour, as a function of the frame where it changes state mid-move. */
41
+ tagColour?: string | ((frame: number) => string);
42
+ /** Tag text colour, for tags too dark for ink to read on. */
43
+ tagText?: string | ((frame: number) => string);
44
+ }> = ({
45
+ stops,
46
+ hideAt,
47
+ label,
48
+ fill = COLOR.accent,
49
+ tagColour = COLOR.accent,
50
+ tagText = COLOR.fg,
51
+ }) => {
52
+ const frame = useCurrentFrame();
53
+ const {fps} = useVideoConfig();
54
+
55
+ const positionAt = (f: number) => {
56
+ let x = stops[0].x;
57
+ let y = stops[0].y;
58
+ for (let i = 1; i < stops.length; i++) {
59
+ const from = stops[i - 1];
60
+ const to = stops[i];
61
+ const distance = Math.hypot(to.x - from.x, to.y - from.y);
62
+ const travel = Math.min(travelFrames(distance, fps), Math.max(1, to.at - from.at));
63
+ const depart = to.at - travel;
64
+ if (f <= depart) break;
65
+ const t = progress(f, depart, travel, EASE.inOut);
66
+ // Arc: bulge perpendicular to the path, peaking mid-way.
67
+ const bulge = Math.sin(Math.PI * t) * distance * 0.1;
68
+ const nx = distance ? -(to.y - from.y) / distance : 0;
69
+ const ny = distance ? (to.x - from.x) / distance : 0;
70
+ x = from.x + (to.x - from.x) * t + nx * bulge;
71
+ y = from.y + (to.y - from.y) * t + ny * bulge;
72
+ }
73
+ return {x, y};
74
+ };
75
+
76
+ const {x, y} = positionAt(frame);
77
+ const click = stops.find((s) => s.click && frame >= s.at && frame < s.at + durFrames('slow', fps));
78
+ const pressT = click ? progress(frame, click.at, CLICK_FRAMES, EASE.inOut) : 0;
79
+ const press = 1 - 0.14 * Math.sin(Math.PI * pressT);
80
+ const ripple = click ? progress(frame, click.at, durFrames('slow', fps)) : 0;
81
+
82
+ // Resolved first so a label that returns an empty string removes the tag outright, which is
83
+ // how "copied" disappears once the line has been pasted.
84
+ const labelText = typeof label === 'function' ? label(frame) : label;
85
+
86
+ return (
87
+ <Presence at={stops[0].at} until={hideAt} dur="fast" from="none" style={{position: 'absolute', inset: 0}}>
88
+ {click ? (
89
+ <div
90
+ style={{
91
+ position: 'absolute',
92
+ left: click.x - 70 * ripple,
93
+ top: click.y - 70 * ripple,
94
+ width: 140 * ripple,
95
+ height: 140 * ripple,
96
+ borderRadius: RADIUS.pill,
97
+ border: `4px solid ${fill}`,
98
+ opacity: 1 - ripple,
99
+ }}
100
+ />
101
+ ) : null}
102
+ <VelocityBlur
103
+ vx={perFrame((f) => positionAt(f).x, frame)}
104
+ vy={perFrame((f) => positionAt(f).y, frame)}
105
+ style={{
106
+ position: 'absolute',
107
+ left: x - TIP,
108
+ top: y - TIP,
109
+ transformOrigin: `${TIP}px ${TIP}px`,
110
+ transform: `scale(${press.toFixed(4)})`,
111
+ filter: `drop-shadow(0 8px 14px ${COLOR.fg}66)`,
112
+ }}
113
+ >
114
+ {/* Solid pointer (as in the reference): tip top-left, notched tail. */}
115
+ <svg width={POINTER} height={POINTER} viewBox="0 0 64 64">
116
+ <path
117
+ d="M6 6 L58 26 Q60 27 58 28 L35 35 L28 58 Q27 60 26 58 Z"
118
+ fill={fill}
119
+ stroke={COLOR.fg}
120
+ strokeWidth={3}
121
+ strokeLinejoin="round"
122
+ />
123
+ </svg>
124
+ </VelocityBlur>
125
+
126
+ {labelText ? (
127
+ <div
128
+ style={{
129
+ position: 'absolute',
130
+ // Sits behind and below the tip, where a Figma cursor label sits, so it never
131
+ // covers the thing being pointed at.
132
+ left: x + POINTER * 0.42,
133
+ top: y + POINTER * 0.52,
134
+ display: 'flex',
135
+ alignItems: 'center',
136
+ padding: '7px 16px',
137
+ borderRadius: RADIUS.pill,
138
+ backgroundColor: typeof tagColour === 'function' ? tagColour(frame) : tagColour,
139
+ fontFamily: FONT.sans,
140
+ fontSize: 26,
141
+ fontWeight: WEIGHT.semibold,
142
+ letterSpacing: '-0.015em',
143
+ color: typeof tagText === 'function' ? tagText(frame) : tagText,
144
+ whiteSpace: 'nowrap',
145
+ boxShadow: `0 6px 16px ${COLOR.fg}33`,
146
+ }}
147
+ >
148
+ {labelText}
149
+ </div>
150
+ ) : null}
151
+ </Presence>
152
+ );
153
+ };
@@ -0,0 +1,42 @@
1
+ import React from 'react';
2
+ import {useCurrentFrame} from 'remotion';
3
+ import {EASE} from '../design/motion';
4
+ import {progress} from '../lib/animate';
5
+ import {pinStyle, sampleTrack, type TrackFile} from '../lib/track';
6
+
7
+ /**
8
+ * Text or a graphic that rides with the speaker's head (motion tracking). Never a default — use it when
9
+ * someone asks for it, on a stretch that has been tracked with scripts/track_head.py.
10
+ *
11
+ * <HeadPin track={headTrack} toSource={(f) => f + 72} from={5} to={53} dy={-330}>
12
+ * <div style={…}>Big title</div>
13
+ * </HeadPin>
14
+ *
15
+ * dx/dy place it relative to the head (at the head's starting size; they grow as the head comes closer).
16
+ * It follows two frames behind and takes half the head's tilt — attached, not glued. Outside the tracked
17
+ * frames it simply does not draw, so it can never float in the wrong place.
18
+ */
19
+ export const HeadPin: React.FC<{
20
+ track: TrackFile;
21
+ /** The source frame of the tracked clip shown at a composition frame (null when the clip is not on screen). */
22
+ toSource: (frame: number) => number | null;
23
+ from: number;
24
+ to: number;
25
+ dx?: number;
26
+ dy?: number;
27
+ tilt?: number;
28
+ children: React.ReactNode;
29
+ }> = ({track, toSource, from, to, dx = 0, dy = 0, tilt = 0.5, children}) => {
30
+ const f = useCurrentFrame();
31
+ if (f < from - 2 || f > to) return null;
32
+ const src = toSource(f);
33
+ const head = src === null ? null : sampleTrack(track, src, 2);
34
+ if (!head) return null;
35
+ const p = progress(f, from, 8, EASE.overshoot);
36
+ const out = 1 - progress(f, to - 5, 5, EASE.in);
37
+ return (
38
+ <div style={{...pinStyle(head, {dx, dy, tilt}), opacity: Math.min(1, p * 1.6) * out}}>
39
+ <div style={{transform: `scale(${0.85 + Math.min(1.05, p) * 0.15}) translateY(${(1 - Math.min(1, p)) * 20}px)`, display: 'flex', flexDirection: 'column', alignItems: 'center'}}>{children}</div>
40
+ </div>
41
+ );
42
+ };
@@ -3,7 +3,8 @@ import {AbsoluteFill, Sequence} from 'remotion';
3
3
  import {DataLine} from '../components/DataLine';
4
4
  import {MorphState} from '../components/MorphState';
5
5
  import {TypeAhead} from '../components/TypeAhead';
6
- import {FONT, PALETTE, SHADOW, WEIGHT} from '../design/tokens';
6
+ import {withAlpha} from '../design/color';
7
+ import {COLOR, FONT, SHADOW, WEIGHT} from '../design/tokens';
7
8
 
8
9
  /**
9
10
  * The three beats added from the UI-motion reference, side by side so they can be judged before
@@ -13,10 +14,10 @@ import {FONT, PALETTE, SHADOW, WEIGHT} from '../design/tokens';
13
14
  * Render stills with: node scripts/stills.mjs BeatTest --frames=14,30,46,70
14
15
  */
15
16
 
16
- const LIME = PALETTE.lime400;
17
+ const ACCENT = COLOR.accent;
17
18
 
18
19
  const Label: React.FC<{children: React.ReactNode}> = ({children}) => (
19
- <div style={{fontFamily: FONT.sans, fontSize: 24, fontWeight: WEIGHT.semibold, letterSpacing: '0.06em', color: '#8a8880', textTransform: 'uppercase'}}>
20
+ <div style={{fontFamily: FONT.sans, fontSize: 24, fontWeight: WEIGHT.semibold, letterSpacing: '0.06em', color: COLOR.fgSecondary, textTransform: 'uppercase'}}>
20
21
  {children}
21
22
  </div>
22
23
  );
@@ -24,7 +25,7 @@ const Label: React.FC<{children: React.ReactNode}> = ({children}) => (
24
25
  export const BEAT_TEST_FRAMES = 90;
25
26
 
26
27
  export const BeatTest: React.FC = () => (
27
- <AbsoluteFill style={{backgroundColor: PALETTE.cream, fontFamily: FONT.sans, padding: 80, gap: 56, display: 'flex', flexDirection: 'column'}}>
28
+ <AbsoluteFill style={{backgroundColor: COLOR.surfaceAlt, fontFamily: FONT.sans, padding: 80, gap: 56, display: 'flex', flexDirection: 'column'}}>
28
29
  {/* 1. Morph into state — a payment row becoming its own confirmation. */}
29
30
  <div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
30
31
  <Label>Morph into state</Label>
@@ -35,8 +36,8 @@ export const BeatTest: React.FC = () => (
35
36
  width: 620,
36
37
  height: 104,
37
38
  radius: 999,
38
- background: PALETTE.white,
39
- colour: PALETTE.ink900,
39
+ background: COLOR.surface,
40
+ colour: COLOR.fg,
40
41
  children: (
41
42
  <span style={{fontSize: 34, fontWeight: WEIGHT.medium, padding: '0 36px'}}>
42
43
  Send ₹2,000 to Wise account
@@ -47,8 +48,8 @@ export const BeatTest: React.FC = () => (
47
48
  width: 340,
48
49
  height: 104,
49
50
  radius: 999,
50
- background: LIME,
51
- colour: PALETTE.ink900,
51
+ background: ACCENT,
52
+ colour: COLOR.onAccent,
52
53
  children: <span style={{fontSize: 36, fontWeight: WEIGHT.bold}}>Received ✓</span>,
53
54
  }}
54
55
  />
@@ -57,7 +58,7 @@ export const BeatTest: React.FC = () => (
57
58
  {/* 2. Live data morph — the panel holds still, only the line changes. */}
58
59
  <div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
59
60
  <Label>Live data morph</Label>
60
- <div style={{width: 820, height: 260, borderRadius: 24, backgroundColor: PALETTE.white, border: '1px solid rgba(18,18,16,0.10)', padding: 32, boxShadow: SHADOW.pop}}>
61
+ <div style={{width: 820, height: 260, borderRadius: 24, backgroundColor: COLOR.surface, border: `1px solid ${COLOR.borderSubtle}`, padding: 32, boxShadow: SHADOW.pop}}>
61
62
  <DataLine
62
63
  width={756}
63
64
  height={196}
@@ -67,9 +68,9 @@ export const BeatTest: React.FC = () => (
67
68
  frames={20}
68
69
  from={[0.22, 0.44, 0.3, 0.58, 0.41, 0.69, 0.52, 0.78, 0.6, 0.72]}
69
70
  to={[0.3, 0.26, 0.52, 0.38, 0.72, 0.5, 0.86, 0.62, 0.9, 0.84]}
70
- colour="#3b82f6"
71
- toColour="#e5484d"
72
- fill="rgba(229,72,77,0.07)"
71
+ colour={COLOR.fgSecondary}
72
+ toColour={ACCENT}
73
+ fill={withAlpha(ACCENT, 0.1)}
73
74
  />
74
75
  </div>
75
76
  </div>
@@ -82,9 +83,9 @@ export const BeatTest: React.FC = () => (
82
83
  text="cut the gaps and add captions"
83
84
  at={8}
84
85
  speed={1.3}
85
- colour={PALETTE.ink900}
86
- ghost="rgba(18,18,16,0.22)"
87
- background={LIME}
86
+ colour={COLOR.onAccent}
87
+ ghost={withAlpha(COLOR.fg, 0.22)}
88
+ background={ACCENT}
88
89
  fontSize={42}
89
90
  fontFamily={FONT.sans}
90
91
  fontWeight={WEIGHT.semibold}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Motion tracking: pin graphics to something moving in the footage — a head, from scripts/track_head.py.
3
+ *
4
+ * const head = sampleTrack(track, sourceFrame, 2); // null outside the tracked stretches
5
+ * if (head) <div style={pinStyle(head, {dy: -330})}>…</div>
6
+ *
7
+ * A track holds SOURCE frames of one clip, in that clip's pixels; a composition showing the clip
8
+ * full-frame at the same size can use it directly. One file per video can hold several tracked stretches.
9
+ * `lag` follows a couple of frames behind, so a graphic feels attached by a soft spring, not glued on.
10
+ */
11
+ import type {CSSProperties} from 'react';
12
+
13
+ export type TrackPoint = {f: number; x: number; y: number; s: number; r: number};
14
+ export type TrackFile = {fps: number; size: number[]; first: number; box: {w: number; h: number}; frames: TrackPoint[]};
15
+ export type Pin = {x: number; y: number; s: number; r: number};
16
+
17
+ /** The tracked point at a source frame, or null if that frame was not tracked. */
18
+ function pointAt(t: TrackFile, f: number): TrackPoint | null {
19
+ const fr = Math.round(f);
20
+ let lo = 0;
21
+ let hi = t.frames.length - 1;
22
+ while (lo <= hi) {
23
+ const mid = (lo + hi) >> 1;
24
+ const m = t.frames[mid];
25
+ if (m.f === fr) return m;
26
+ if (m.f < fr) lo = mid + 1;
27
+ else hi = mid - 1;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /** Where the tracked thing is at a source frame, averaged over `lag` frames behind it; null if untracked. */
33
+ export function sampleTrack(t: TrackFile, sourceFrame: number, lag = 0): Pin | null {
34
+ const pts = Array.from({length: lag + 1}, (_, k) => pointAt(t, sourceFrame - k)).filter((p): p is TrackPoint => !!p);
35
+ if (!pointAt(t, sourceFrame) || !pts.length) return null;
36
+ const avg = (k: 'x' | 'y' | 's' | 'r') => pts.reduce((sum, p) => sum + p[k], 0) / pts.length;
37
+ return {x: avg('x'), y: avg('y'), s: avg('s'), r: avg('r')};
38
+ }
39
+
40
+ /**
41
+ * Absolute position for a graphic pinned to the track. dx/dy are offsets at the starting size and grow with
42
+ * the head; `tilt` is how much of the head's tilt the graphic takes (a little reads as attached; all of it
43
+ * reads as stuck on).
44
+ */
45
+ export function pinStyle(p: Pin, {dx = 0, dy = 0, tilt = 0.5}: {dx?: number; dy?: number; tilt?: number} = {}): CSSProperties {
46
+ return {
47
+ position: 'absolute',
48
+ left: p.x + dx * p.s,
49
+ top: p.y + dy * p.s,
50
+ transform: `translate(-50%, -50%) scale(${p.s}) rotate(${p.r * tilt}deg)`,
51
+ transformOrigin: '50% 50%',
52
+ };
53
+ }
54
+
55
+ /**
56
+ * How far the head has moved since a reference frame, as a CSS transform about the head — for "follow":
57
+ * a layout keeps its design but moves, scales and (partly) tilts with the head. Null if either is untracked.
58
+ */
59
+ export function followTransform(t: TrackFile, sourceFrame: number, fromSourceFrame: number, tilt = 0.5): {transform: string; transformOrigin: string} | null {
60
+ const now = sampleTrack(t, sourceFrame, 2);
61
+ const at = sampleTrack(t, fromSourceFrame);
62
+ if (!now || !at) return null;
63
+ return {
64
+ transform: `translate(${now.x - at.x}px, ${now.y - at.y}px) scale(${now.s / at.s}) rotate(${(now.r - at.r) * tilt}deg)`,
65
+ transformOrigin: `${at.x}px ${at.y}px`,
66
+ };
67
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Live-preview adjustments ("tweaks"): what the preview's instant controls change, applied to an edit
3
+ * without touching its code.
4
+ *
5
+ * An edit wraps itself in <TweaksProvider beatStarts={…} tweaks={…}> and wraps each layer that should
6
+ * respond in <Tweaked>. In the preview the tweaks arrive as props and change the video instantly; saved,
7
+ * they live in the video's tweaks.json and the final render reads them from there. No tweaks = the edit
8
+ * exactly as built.
9
+ *
10
+ * Rules that keep an edit correct whatever someone clicks:
11
+ * - Anything that hides a join in the cut (a full-screen card) must NOT be inside a moving <Tweaked> — the
12
+ * card's exact frames are what hide the join. Wrap only what sits ON it.
13
+ * - Layers locked to the footage (a subject cutout) use <Tweaked move={false}> and read the tweak with
14
+ * useHereTweak() to move only their graphics.
15
+ */
16
+ import React, {createContext, useContext} from 'react';
17
+ import {AbsoluteFill, Sequence, useCurrentFrame} from 'remotion';
18
+ import {followTransform, type TrackFile} from './track';
19
+
20
+ /**
21
+ * One beat's adjustments. All optional.
22
+ * scale size of text and graphics, 0.7–1.4 dy up (−) / down (+), px, ±300
23
+ * shift frames later (+) / earlier (−), ±12 gain that beat's sound effects, dB, −12…+6
24
+ * mute that beat's sound effects off icon swap a 3D icon: {cart: 'shop'}
25
+ * follow the beat's graphics move, scale and partly tilt with the speaker's head (needs a head track)
26
+ */
27
+ export type Tweak = {scale?: number; dy?: number; shift?: number; gain?: number; mute?: boolean; icon?: Record<string, string>; follow?: boolean};
28
+ export type Tweaks = Record<string, Tweak>; // keyed by beat index
29
+
30
+ export const TWEAK_LIMITS = {scale: [0.7, 1.4], dy: [-300, 300], shift: [-12, 12], gain: [-12, 6]} as const;
31
+
32
+ /** track/toSource: optional head track for the "follow" tweak — the clip's source frame at a composition frame. */
33
+ type Ctx = {tweaks: Tweaks; beatAt: (frame: number) => number; beatStarts: number[]; track?: TrackFile; toSource?: (frame: number) => number | null};
34
+ const All = createContext<Ctx>({tweaks: {}, beatAt: () => 0, beatStarts: [0]});
35
+ const Here = createContext<Tweak>({});
36
+
37
+ /** beatStarts: the first frame of each beat, in order. */
38
+ export const TweaksProvider: React.FC<{tweaks?: Tweaks; beatStarts: number[]; track?: TrackFile; toSource?: (frame: number) => number | null; children: React.ReactNode}> = ({
39
+ tweaks = {},
40
+ beatStarts,
41
+ track,
42
+ toSource,
43
+ children,
44
+ }) => {
45
+ const beatAt = (frame: number) => {
46
+ for (let i = beatStarts.length - 1; i >= 0; i--) if (frame >= beatStarts[i]) return i;
47
+ return 0;
48
+ };
49
+ return <All.Provider value={{tweaks, beatAt, beatStarts, track, toSource}}>{children}</All.Provider>;
50
+ };
51
+
52
+ /** The tweak of the beat at any frame — for things placed by frame number, like sound cues. */
53
+ export const useTweakAt = () => {
54
+ const {tweaks, beatAt} = useContext(All);
55
+ return (frame: number): Tweak => tweaks[String(beatAt(frame))] ?? {};
56
+ };
57
+
58
+ /** The tweak of the beat being drawn, inside a <Tweaked>. */
59
+ export const useHereTweak = () => useContext(Here);
60
+
61
+ /**
62
+ * Applies the current beat's size, position and timing to what is inside it. `move={false}` only hands the
63
+ * tweak down (see the rules above).
64
+ */
65
+ export const Tweaked: React.FC<{move?: boolean; children: React.ReactNode}> = ({move = true, children}) => {
66
+ const f = useCurrentFrame();
67
+ const ctx = useContext(All);
68
+ const t = useTweakAt()(f);
69
+ // Follow: the beat keeps its layout but moves, scales and partly tilts with the head, measured from the
70
+ // first frame of the beat where the head is visible and tracked (a beat may open on a full-screen card).
71
+ // Untracked frames leave it exactly where it was designed.
72
+ let follow: React.CSSProperties | null = null;
73
+ if (move && t.follow && ctx.track && ctx.toSource) {
74
+ const track = ctx.track;
75
+ const src = ctx.toSource(f);
76
+ const beat = ctx.beatAt(f);
77
+ let src0: number | null = null;
78
+ for (let g = ctx.beatStarts[beat] ?? 0; g <= f && src0 === null; g++) {
79
+ const s = ctx.toSource(g);
80
+ if (s !== null && followTransform(track, s, s)) src0 = s;
81
+ }
82
+ follow = src !== null && src0 !== null ? followTransform(track, src, src0) : null;
83
+ }
84
+ const placed = (
85
+ <Here.Provider value={t}>
86
+ {move && (t.scale || t.dy) ? <AbsoluteFill style={{transform: `translateY(${t.dy ?? 0}px) scale(${t.scale ?? 1})`, transformOrigin: '50% 55%'}}>{children}</AbsoluteFill> : children}
87
+ </Here.Provider>
88
+ );
89
+ const inner = follow ? <AbsoluteFill style={follow}>{placed}</AbsoluteFill> : placed;
90
+ // Later (+) or earlier (−): everything inside runs on a clock offset by that many frames.
91
+ return move && t.shift ? (
92
+ <Sequence from={t.shift} layout="none">
93
+ {inner}
94
+ </Sequence>
95
+ ) : (
96
+ inner
97
+ );
98
+ };