creator-editing-studio 1.3.2 → 1.3.5
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/package.json +1 -1
- package/template/CLAUDE.md +45 -0
- package/template/scripts/refs.mjs +1 -1
- package/template/src/components/CommandCard.tsx +1 -1
- package/template/src/components/DataLine.tsx +103 -0
- package/template/src/components/MorphState.tsx +128 -0
- package/template/src/components/TypeAhead.tsx +72 -0
- package/template/src/compositions/BeatTest.tsx +95 -0
- package/template/templates/BRIEF.md +5 -0
package/package.json
CHANGED
package/template/CLAUDE.md
CHANGED
|
@@ -134,6 +134,51 @@ The plan is there to save them a rebuild, not to put a form in front of them.
|
|
|
134
134
|
The failure this is guarding against is a whole edit rendered the wrong way. Anything that does
|
|
135
135
|
not risk that should move fast.
|
|
136
136
|
|
|
137
|
+
## What makes motion read as expensive
|
|
138
|
+
|
|
139
|
+
The twelve motion laws stop an edit looking broken. These seven are what separate motion that
|
|
140
|
+
looks competent from motion that looks paid for. They were taken from studying reference edits
|
|
141
|
+
frame by frame, and they apply to UI, graphics and footage alike.
|
|
142
|
+
|
|
143
|
+
**1. Blur anything that moves fast.** In the edits that feel most expensive, nothing travels
|
|
144
|
+
sharp. An element crossing the frame in six frames should be a smear that only resolves as it
|
|
145
|
+
slows — and that includes digits rolling from one number to another, and a line redrawing on a
|
|
146
|
+
chart. Sharp fast motion is the single clearest tell of cheap animation, because real cameras and
|
|
147
|
+
real eyes do not work that way. Use the velocity blur helpers rather than deciding per element.
|
|
148
|
+
|
|
149
|
+
**2. Morph, don't replace.** When something becomes something else, animate the thing itself
|
|
150
|
+
rather than fading one out and another in. A row that is clicked and confirmed should stretch and
|
|
151
|
+
turn into the confirmation; it should not be covered by a new element that happens to sit in the
|
|
152
|
+
same place. An element that exists in two moments travels between them. This is the law that most
|
|
153
|
+
reliably makes a sequence feel connected rather than assembled.
|
|
154
|
+
|
|
155
|
+
**3. Overshoot, then settle.** Things that arrive exactly at their final size and stop look
|
|
156
|
+
pasted on. Let an arrival come in slightly too large, or slightly past its mark, and ease back.
|
|
157
|
+
The overshoot is small — a few per cent — and it is the difference between landing and being
|
|
158
|
+
placed.
|
|
159
|
+
|
|
160
|
+
**4. Animate the data, not the container.** When a value, a chart or a list changes, hold the
|
|
161
|
+
frame still and change only what actually changed. Re-entering the whole panel to show a new
|
|
162
|
+
number throws away the viewer's orientation and makes the change read as a new scene instead of
|
|
163
|
+
an update.
|
|
164
|
+
|
|
165
|
+
**5. Reveal one thing at a time.** Rows, cards, icons and bullets arrive in reading order with a
|
|
166
|
+
few frames between them, never as a group. If a container also needs to grow, grow it slowly
|
|
167
|
+
underneath while the contents arrive — two speeds, not one.
|
|
168
|
+
|
|
169
|
+
**6. Earn the hard cut.** In a piece built from continuous motion, a single hard cut to a flat
|
|
170
|
+
colour lands hard precisely because it is the only one. Bursts of hard cuts are their own beat
|
|
171
|
+
and work too, but mixing the two at random wastes both. Decide which the piece is.
|
|
172
|
+
|
|
173
|
+
**7. Show what is coming.** Small anticipations make an interface feel alive: text typing into a
|
|
174
|
+
pill that grows with it, with the next word already faintly present; a cursor that starts moving
|
|
175
|
+
before the thing it will click appears. The viewer reads intent, not just motion.
|
|
176
|
+
|
|
177
|
+
A note on where these come from. Every one of them was found by studying an edit frame by frame
|
|
178
|
+
and writing down what it actually did — not by reading about animation. When something looks good
|
|
179
|
+
to you, save the clip and the timecode and do the same. That habit is worth more than any list,
|
|
180
|
+
including this one.
|
|
181
|
+
|
|
137
182
|
## Verify the render before you call it done
|
|
138
183
|
|
|
139
184
|
You have not seen the video. The timeline in your head is not evidence. Before you say an
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* npm run refs -- --all redo every sheet
|
|
8
8
|
*
|
|
9
9
|
* Reads entries shaped like:
|
|
10
|
-
* ## 4. `
|
|
10
|
+
* ## 4. `clip-name.mp4` — Creator name
|
|
11
11
|
* - **00:11–00:14** — what happens...
|
|
12
12
|
* Writes work/refs/<clip>-<mm.ss>-<mm.ss>.jpg and work/refs/INDEX.md (moment -> sheet), and lists
|
|
13
13
|
* clips that are in the folder but have no entry yet, or entries whose clip is missing.
|
|
@@ -205,7 +205,7 @@ export const CONNECT_SEGMENTS: Segment[] = [
|
|
|
205
205
|
{kind: 'plain', text: 'claude mcp add '},
|
|
206
206
|
{kind: 'flag', text: '--scope user --transport http '},
|
|
207
207
|
{kind: 'accent', text: 'creator-editing-os'},
|
|
208
|
-
{kind: 'plain', text: '
|
|
208
|
+
{kind: 'plain', text: ' <your connector URL>'},
|
|
209
209
|
{kind: 'redacted', text: 'XXXX-XXXX-XXXX-XXXX'},
|
|
210
210
|
];
|
|
211
211
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {useCurrentFrame, useVideoConfig} from 'remotion';
|
|
3
|
+
import {EASE} from '../design/motion';
|
|
4
|
+
import {progress} from '../lib/animate';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Live data morph — the chart holds still and only the data moves.
|
|
8
|
+
*
|
|
9
|
+
* The approved beat. When a value, a line or a list changes, the frame does not reset: the axes,
|
|
10
|
+
* the panel and the grid stay exactly where they are, and the line redraws into its new shape and
|
|
11
|
+
* recolours as it goes. Re-entering the whole panel to show a new number throws away the viewer's
|
|
12
|
+
* orientation and makes an update read as a new scene.
|
|
13
|
+
*
|
|
14
|
+
* Two series morph into each other point by point, so they must have the same number of points —
|
|
15
|
+
* resample before passing them in if they do not. Values are 0..1, bottom to top.
|
|
16
|
+
*
|
|
17
|
+
* `draw` handles the first appearance: the line is drawn on left to right by a dash offset, which
|
|
18
|
+
* is a playhead rather than a motion, so it runs linear in time.
|
|
19
|
+
*/
|
|
20
|
+
export const DataLine: React.FC<{
|
|
21
|
+
/** The series it starts on. Values 0..1. */
|
|
22
|
+
from: number[];
|
|
23
|
+
/** The series it becomes. Same length as `from`. Omit to only draw `from`. */
|
|
24
|
+
to?: number[];
|
|
25
|
+
/** Frame the morph begins, relative to the enclosing sequence. */
|
|
26
|
+
at: number;
|
|
27
|
+
/** How long the morph takes. Default 0.6s. */
|
|
28
|
+
frames?: number;
|
|
29
|
+
/** Frame the line draws itself on. Omit to have it present from the start. */
|
|
30
|
+
drawAt?: number;
|
|
31
|
+
drawFrames?: number;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
colour: string;
|
|
35
|
+
/** Colour it becomes, if the state change carries a colour change too. */
|
|
36
|
+
toColour?: string;
|
|
37
|
+
strokeWidth?: number;
|
|
38
|
+
/** Faint fill under the line. */
|
|
39
|
+
fill?: string;
|
|
40
|
+
}> = ({from, to, at, frames, drawAt, drawFrames, width, height, colour, toColour, strokeWidth = 5, fill}) => {
|
|
41
|
+
const frame = useCurrentFrame();
|
|
42
|
+
const {fps} = useVideoConfig();
|
|
43
|
+
|
|
44
|
+
if (to && to.length !== from.length) {
|
|
45
|
+
throw new Error(`DataLine: series must be the same length (${from.length} vs ${to.length}). Resample first.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const t = to ? progress(frame, at, frames ?? Math.round(0.6 * fps), EASE.inOut) : 0;
|
|
49
|
+
const values = from.map((v, i) => (to ? v + (to[i] - v) * t : v));
|
|
50
|
+
|
|
51
|
+
const pts = values.map((v, i) => {
|
|
52
|
+
const x = (i / Math.max(1, values.length - 1)) * width;
|
|
53
|
+
const y = height - v * height;
|
|
54
|
+
return [x, y] as const;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** A Catmull-Rom style smoothing, so the line curves rather than kinking at every sample. */
|
|
58
|
+
const d = pts
|
|
59
|
+
.map(([x, y], i) => {
|
|
60
|
+
if (i === 0) return `M${x.toFixed(2)} ${y.toFixed(2)}`;
|
|
61
|
+
const [px, py] = pts[i - 1];
|
|
62
|
+
const cx = (px + x) / 2;
|
|
63
|
+
return `C${cx.toFixed(2)} ${py.toFixed(2)} ${cx.toFixed(2)} ${y.toFixed(2)} ${x.toFixed(2)} ${y.toFixed(2)}`;
|
|
64
|
+
})
|
|
65
|
+
.join(' ');
|
|
66
|
+
|
|
67
|
+
// Generous over-estimate of the path length; exactness does not matter for a dash wipe.
|
|
68
|
+
const LEN = width * 2.2;
|
|
69
|
+
const drawn =
|
|
70
|
+
drawAt === undefined ? 1 : Math.max(0, Math.min(1, (frame - drawAt) / (drawFrames ?? Math.round(1 * fps))));
|
|
71
|
+
|
|
72
|
+
const stroke = toColour ? blend(colour, toColour, t) : colour;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<svg width={width} height={height} style={{overflow: 'visible'}}>
|
|
76
|
+
{fill ? (
|
|
77
|
+
<path d={`${d} L${width} ${height} L0 ${height} Z`} fill={fill} opacity={drawn} stroke="none" />
|
|
78
|
+
) : null}
|
|
79
|
+
<path
|
|
80
|
+
d={d}
|
|
81
|
+
fill="none"
|
|
82
|
+
stroke={stroke}
|
|
83
|
+
strokeWidth={strokeWidth}
|
|
84
|
+
strokeLinecap="round"
|
|
85
|
+
strokeLinejoin="round"
|
|
86
|
+
strokeDasharray={drawAt === undefined ? undefined : LEN}
|
|
87
|
+
strokeDashoffset={drawAt === undefined ? undefined : LEN * (1 - drawn)}
|
|
88
|
+
/>
|
|
89
|
+
</svg>
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Straight sRGB blend. Good enough between two brand colours; not a perceptual mix. */
|
|
94
|
+
const blend = (a: string, b: string, t: number): string => {
|
|
95
|
+
const parse = (h: string) => {
|
|
96
|
+
const n = parseInt(h.replace('#', ''), 16);
|
|
97
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
98
|
+
};
|
|
99
|
+
const [r1, g1, b1] = parse(a);
|
|
100
|
+
const [r2, g2, b2] = parse(b);
|
|
101
|
+
const m = (x: number, y: number) => Math.round(x + (y - x) * t);
|
|
102
|
+
return `rgb(${m(r1, r2)}, ${m(g1, g2)}, ${m(b1, b2)})`;
|
|
103
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React, {type CSSProperties} from 'react';
|
|
2
|
+
import {useCurrentFrame, useVideoConfig} from 'remotion';
|
|
3
|
+
import {EASE} from '../design/motion';
|
|
4
|
+
import {perFrame, progress} from '../lib/animate';
|
|
5
|
+
import {VelocityBlur} from './VelocityBlur';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Morph into state — one element becoming its next state, in place.
|
|
9
|
+
*
|
|
10
|
+
* The approved beat. When something changes meaning — a row that is clicked and confirmed, a
|
|
11
|
+
* button that becomes a success pill, a card that becomes a badge — the element itself stretches,
|
|
12
|
+
* recolours and swaps its contents. It is never covered by a second element that happens to sit
|
|
13
|
+
* in the same place, and it never fades out while a replacement fades in.
|
|
14
|
+
*
|
|
15
|
+
* This is the single change that most reliably makes a sequence feel connected rather than
|
|
16
|
+
* assembled, because the viewer's eye stays on one object for the whole move instead of being
|
|
17
|
+
* handed off between two.
|
|
18
|
+
*
|
|
19
|
+
* What morphs: width, height, corner radius, background and text colour. The children cross-fade
|
|
20
|
+
* across the middle of the move — late enough that the shape has already started changing, so the
|
|
21
|
+
* swap reads as the same object relabelling itself rather than a cut.
|
|
22
|
+
*
|
|
23
|
+
* Travel is velocity-blurred, because nothing fast should be sharp.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type MorphFace = {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
radius?: number;
|
|
30
|
+
background?: string;
|
|
31
|
+
colour?: string;
|
|
32
|
+
children?: React.ReactNode;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const MorphState: React.FC<{
|
|
36
|
+
/** The state it starts in. */
|
|
37
|
+
from: MorphFace;
|
|
38
|
+
/** The state it becomes. */
|
|
39
|
+
to: MorphFace;
|
|
40
|
+
/** Frame the morph begins, relative to the enclosing sequence. */
|
|
41
|
+
at: number;
|
|
42
|
+
/** How long the morph takes. Default 0.4s — long enough to read, short enough to feel decisive. */
|
|
43
|
+
frames?: number;
|
|
44
|
+
style?: CSSProperties;
|
|
45
|
+
}> = ({from, to, at, frames, style}) => {
|
|
46
|
+
const frame = useCurrentFrame();
|
|
47
|
+
const {fps} = useVideoConfig();
|
|
48
|
+
const dur = frames ?? Math.round(0.4 * fps);
|
|
49
|
+
|
|
50
|
+
// One curve drives every property, so nothing arrives out of step with anything else.
|
|
51
|
+
const p = (f: number) => progress(f, at, dur, EASE.emphasis);
|
|
52
|
+
const t = p(frame);
|
|
53
|
+
|
|
54
|
+
const mix = (a: number, b: number) => a + (b - a) * t;
|
|
55
|
+
const width = mix(from.width, to.width);
|
|
56
|
+
const height = mix(from.height, to.height);
|
|
57
|
+
const radius = mix(from.radius ?? 999, to.radius ?? 999);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Contents swap across the middle 40% of the move. Swapping at the very start shows the new
|
|
61
|
+
* label in the old shape; swapping at the end looks like a glitch on arrival.
|
|
62
|
+
*/
|
|
63
|
+
const swap = Math.max(0, Math.min(1, (t - 0.3) / 0.4));
|
|
64
|
+
|
|
65
|
+
// Width is what actually travels here, so that is what the blur is sized from.
|
|
66
|
+
const vx = perFrame((f) => mix2(from.width, to.width, p(f)), frame);
|
|
67
|
+
const vy = perFrame((f) => mix2(from.height, to.height, p(f)), frame);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<VelocityBlur
|
|
71
|
+
vx={vx}
|
|
72
|
+
vy={vy}
|
|
73
|
+
bleed={0.4}
|
|
74
|
+
style={{
|
|
75
|
+
...style,
|
|
76
|
+
width,
|
|
77
|
+
height,
|
|
78
|
+
borderRadius: radius,
|
|
79
|
+
// Backgrounds cross-fade by stacking, which handles gradients and alpha correctly where
|
|
80
|
+
// a numeric colour interpolation would not.
|
|
81
|
+
backgroundColor: from.background,
|
|
82
|
+
position: 'relative',
|
|
83
|
+
overflow: 'hidden',
|
|
84
|
+
display: 'grid',
|
|
85
|
+
placeItems: 'center',
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
<div
|
|
89
|
+
style={{
|
|
90
|
+
position: 'absolute',
|
|
91
|
+
inset: 0,
|
|
92
|
+
backgroundColor: to.background,
|
|
93
|
+
opacity: t,
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
<Face opacity={1 - swap} colour={from.colour}>
|
|
97
|
+
{from.children}
|
|
98
|
+
</Face>
|
|
99
|
+
<Face opacity={swap} colour={to.colour}>
|
|
100
|
+
{to.children}
|
|
101
|
+
</Face>
|
|
102
|
+
</VelocityBlur>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const mix2 = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
107
|
+
|
|
108
|
+
/** One of the two labels, centred and clipped to the shape. */
|
|
109
|
+
const Face: React.FC<{opacity: number; colour?: string; children?: React.ReactNode}> = ({opacity, colour, children}) => {
|
|
110
|
+
if (opacity <= 0) return null;
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
style={{
|
|
114
|
+
position: 'absolute',
|
|
115
|
+
inset: 0,
|
|
116
|
+
display: 'grid',
|
|
117
|
+
placeItems: 'center',
|
|
118
|
+
opacity,
|
|
119
|
+
color: colour,
|
|
120
|
+
whiteSpace: 'nowrap',
|
|
121
|
+
// Never opacity alone: the outgoing label drifts back slightly as it goes.
|
|
122
|
+
transform: `scale(${0.97 + opacity * 0.03})`,
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{children}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React, {type CSSProperties} from 'react';
|
|
2
|
+
import {useCurrentFrame, useVideoConfig} from 'remotion';
|
|
3
|
+
import {EASE} from '../design/motion';
|
|
4
|
+
import {progress} from '../lib/animate';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ghosted type-ahead — text typing into a container that grows with it, with the words still to
|
|
8
|
+
* come already present in pale grey.
|
|
9
|
+
*
|
|
10
|
+
* The approved beat, and the cheapest way to make an interface feel alive. A plain typewriter
|
|
11
|
+
* makes the viewer wait to find out where a sentence is going; showing the rest faintly means
|
|
12
|
+
* they read the whole thought immediately and then watch it get committed. The container growing
|
|
13
|
+
* with the text is what stops it feeling like a caption pasted over a box.
|
|
14
|
+
*
|
|
15
|
+
* Typing is linear on purpose. A character count is a count, not a motion, and easing it makes
|
|
16
|
+
* the speaker sound like they are slowing down.
|
|
17
|
+
*/
|
|
18
|
+
export const TypeAhead: React.FC<{
|
|
19
|
+
text: string;
|
|
20
|
+
/** Frame typing starts, relative to the enclosing sequence. */
|
|
21
|
+
at: number;
|
|
22
|
+
/** Characters per frame. 1.6 reads as fast but followable at 30fps. */
|
|
23
|
+
speed?: number;
|
|
24
|
+
/** Colour of the committed text. */
|
|
25
|
+
colour: string;
|
|
26
|
+
/** Colour of the text still to come. Keep it faint — it is a hint, not content. */
|
|
27
|
+
ghost: string;
|
|
28
|
+
/** The pill behind it. Pass `null` for bare text. */
|
|
29
|
+
background?: string | null;
|
|
30
|
+
fontSize?: number;
|
|
31
|
+
fontFamily?: string;
|
|
32
|
+
fontWeight?: number;
|
|
33
|
+
style?: CSSProperties;
|
|
34
|
+
}> = ({text, at, speed = 1.6, colour, ghost, background = null, fontSize = 40, fontFamily, fontWeight, style}) => {
|
|
35
|
+
const frame = useCurrentFrame();
|
|
36
|
+
const {fps} = useVideoConfig();
|
|
37
|
+
|
|
38
|
+
const typed = Math.max(0, Math.min(text.length, Math.floor((frame - at) * speed)));
|
|
39
|
+
const written = text.slice(0, typed);
|
|
40
|
+
const ahead = text.slice(typed);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The pill grows in once at the start rather than tracking each character — a container that
|
|
44
|
+
* resizes on every frame judders, and the text inside it never settles.
|
|
45
|
+
*/
|
|
46
|
+
const open = progress(frame, at - 2, Math.round(0.26 * fps), EASE.outDecisive);
|
|
47
|
+
if (open <= 0) return null;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
style={{
|
|
52
|
+
...style,
|
|
53
|
+
display: 'inline-flex',
|
|
54
|
+
alignItems: 'baseline',
|
|
55
|
+
padding: background ? `${fontSize * 0.34}px ${fontSize * 0.58}px` : 0,
|
|
56
|
+
borderRadius: 999,
|
|
57
|
+
backgroundColor: background ?? undefined,
|
|
58
|
+
fontFamily,
|
|
59
|
+
fontSize,
|
|
60
|
+
fontWeight,
|
|
61
|
+
lineHeight: 1.1,
|
|
62
|
+
whiteSpace: 'pre',
|
|
63
|
+
opacity: open,
|
|
64
|
+
transform: `scale(${0.94 + open * 0.06})`,
|
|
65
|
+
transformOrigin: 'left center',
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<span style={{color: colour}}>{written}</span>
|
|
69
|
+
<span style={{color: ghost}}>{ahead}</span>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {AbsoluteFill, Sequence} from 'remotion';
|
|
3
|
+
import {DataLine} from '../components/DataLine';
|
|
4
|
+
import {MorphState} from '../components/MorphState';
|
|
5
|
+
import {TypeAhead} from '../components/TypeAhead';
|
|
6
|
+
import {FONT, PALETTE, SHADOW, WEIGHT} from '../design/tokens';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The three beats added from the UI-motion reference, side by side so they can be judged before
|
|
10
|
+
* any video uses them. Each runs on a loop of its own so a still at almost any frame shows
|
|
11
|
+
* something mid-move rather than at rest.
|
|
12
|
+
*
|
|
13
|
+
* Render stills with: node scripts/stills.mjs BeatTest --frames=14,30,46,70
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const LIME = PALETTE.lime400;
|
|
17
|
+
|
|
18
|
+
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
|
+
{children}
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const BEAT_TEST_FRAMES = 90;
|
|
25
|
+
|
|
26
|
+
export const BeatTest: React.FC = () => (
|
|
27
|
+
<AbsoluteFill style={{backgroundColor: PALETTE.cream, fontFamily: FONT.sans, padding: 80, gap: 56, display: 'flex', flexDirection: 'column'}}>
|
|
28
|
+
{/* 1. Morph into state — a payment row becoming its own confirmation. */}
|
|
29
|
+
<div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
|
|
30
|
+
<Label>Morph into state</Label>
|
|
31
|
+
<MorphState
|
|
32
|
+
at={10}
|
|
33
|
+
style={{boxShadow: SHADOW.pop}}
|
|
34
|
+
from={{
|
|
35
|
+
width: 620,
|
|
36
|
+
height: 104,
|
|
37
|
+
radius: 999,
|
|
38
|
+
background: PALETTE.white,
|
|
39
|
+
colour: PALETTE.ink900,
|
|
40
|
+
children: (
|
|
41
|
+
<span style={{fontSize: 34, fontWeight: WEIGHT.medium, padding: '0 36px'}}>
|
|
42
|
+
Send ₹2,000 to Wise account
|
|
43
|
+
</span>
|
|
44
|
+
),
|
|
45
|
+
}}
|
|
46
|
+
to={{
|
|
47
|
+
width: 340,
|
|
48
|
+
height: 104,
|
|
49
|
+
radius: 999,
|
|
50
|
+
background: LIME,
|
|
51
|
+
colour: PALETTE.ink900,
|
|
52
|
+
children: <span style={{fontSize: 36, fontWeight: WEIGHT.bold}}>Received ✓</span>,
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{/* 2. Live data morph — the panel holds still, only the line changes. */}
|
|
58
|
+
<div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
|
|
59
|
+
<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
|
+
<DataLine
|
|
62
|
+
width={756}
|
|
63
|
+
height={196}
|
|
64
|
+
drawAt={2}
|
|
65
|
+
drawFrames={26}
|
|
66
|
+
at={38}
|
|
67
|
+
frames={20}
|
|
68
|
+
from={[0.22, 0.44, 0.3, 0.58, 0.41, 0.69, 0.52, 0.78, 0.6, 0.72]}
|
|
69
|
+
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)"
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{/* 3. Ghosted type-ahead — the rest of the sentence is already there, faintly. */}
|
|
78
|
+
<div style={{display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'flex-start'}}>
|
|
79
|
+
<Label>Ghosted type-ahead</Label>
|
|
80
|
+
<Sequence from={0} layout="none">
|
|
81
|
+
<TypeAhead
|
|
82
|
+
text="cut the gaps and add captions"
|
|
83
|
+
at={8}
|
|
84
|
+
speed={1.3}
|
|
85
|
+
colour={PALETTE.ink900}
|
|
86
|
+
ghost="rgba(18,18,16,0.22)"
|
|
87
|
+
background={LIME}
|
|
88
|
+
fontSize={42}
|
|
89
|
+
fontFamily={FONT.sans}
|
|
90
|
+
fontWeight={WEIGHT.semibold}
|
|
91
|
+
/>
|
|
92
|
+
</Sequence>
|
|
93
|
+
</div>
|
|
94
|
+
</AbsoluteFill>
|
|
95
|
+
);
|
|
@@ -90,3 +90,8 @@ Music: added by me in Premiere / Instagram <!-- or a file name in audio/ -->
|
|
|
90
90
|
| Cut to the music | Whole edit built off the track: slow intro, faster build, hardest on the drop |
|
|
91
91
|
| Film look | Grain, halation, light leak, gate weave (looks: clean, film, super8, cinema) |
|
|
92
92
|
| Sound pass | Whooshes, impacts and risers placed off the cut list, ducked under the voice |
|
|
93
|
+
| Morph into state | An element becomes its next state in place — the row you click turns into the confirmation, instead of one thing fading out and another in |
|
|
94
|
+
| Directional blur | Anything moving fast smears and only resolves as it slows, including rolling digits and redrawing lines |
|
|
95
|
+
| Overshoot and settle | Arrivals come in slightly too big, or just past the mark, and ease back |
|
|
96
|
+
| Live data morph | A chart or list updates in place: only the data moves, the panel never re-enters |
|
|
97
|
+
| Ghosted type-ahead | Text types itself into a pill that grows with it, the words still to come already there in pale grey |
|