creator-editing-studio 1.3.5 → 1.3.6

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,103 +1,103 @@
1
- """Word-level transcript for a clip: WhisperX transcription + wav2vec2 forced alignment.
2
-
3
- Usage (from the project root):
4
- python scripts/transcribe.py <media> [--language en] [--model small]
5
-
6
- Writes work/<clip-name>/words.json. Models are cached under the studio's .models folder.
7
- Words the aligner cannot place (often digits like "186%") keep start/end = null and must be
8
- fixed against the script before building the spine.
9
- """
10
-
11
- import argparse
12
- import json
13
- import os
14
- import time
15
- from pathlib import Path
16
-
17
- import sys as _sys # noqa: E402
18
- _sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
19
- from media import FFMPEG, use_certifi # noqa: E402
20
-
21
- # Models are large; cache them inside the studio so they survive and are easy to find.
22
- _MODELS = Path(__file__).resolve().parent.parent / ".models"
23
- os.environ.setdefault("HF_HOME", str(_MODELS / "huggingface"))
24
- os.environ.setdefault("TORCH_HOME", str(_MODELS / "torch"))
25
- os.environ["PATH"] = str(Path(FFMPEG).parent) + os.pathsep + os.environ["PATH"]
26
-
27
- # torch.hub fetches the word-alignment model with urllib, which on a python.org macOS
28
- # build has no root certificates — transcription then dies with CERTIFICATE_VERIFY_FAILED
29
- # after the slow part has already succeeded. Must run before torch is imported.
30
- use_certifi()
31
-
32
- try:
33
- import whisperx # noqa: E402 (must import after the cache/PATH/cert setup above)
34
- except ModuleNotFoundError as exc: # pragma: no cover
35
- # Run with the wrong interpreter and the bare error reads as "never installed", which
36
- # sends you off to fix the wrong thing. Say which python is running and what to do.
37
- _venv = Path(__file__).resolve().parent.parent / ".venv"
38
- _venv_py = _venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python3")
39
- print(f"Cannot import {exc.name}.", file=_sys.stderr)
40
- print(f" running: {_sys.executable} (python {_sys.version.split()[0]})", file=_sys.stderr)
41
- if _venv_py.exists():
42
- print(f" the studio's environment is at {_venv_py}", file=_sys.stderr)
43
- print(" run the transcriber with that python, or use: npm run prep", file=_sys.stderr)
44
- else:
45
- print(" the studio has no .venv yet. Build one with python 3.11 or 3.12:", file=_sys.stderr)
46
- print(" py -3.12 -m venv .venv", file=_sys.stderr)
47
- print(" .venv/Scripts/python.exe -m pip install -r requirements.txt", file=_sys.stderr)
48
- print(" see what you have with: npm run doctor", file=_sys.stderr)
49
- raise SystemExit(2)
50
-
51
-
52
- def main() -> None:
53
- parser = argparse.ArgumentParser()
54
- parser.add_argument("media")
55
- parser.add_argument("--language", default=None, help="e.g. en, hi. Detected when omitted.")
56
- parser.add_argument("--project", default=None, help="Video project name: writes to work/<project>/<clip>/")
57
- parser.add_argument("--model", default="small", help="Whisper size: small (fast on CPU) or medium (more accurate)")
58
- args = parser.parse_args()
59
-
60
- media = Path(args.media)
61
- out_dir = (Path("work") / args.project / media.stem) if args.project else (Path("work") / media.stem)
62
- out_dir.mkdir(parents=True, exist_ok=True)
63
- started = time.time()
64
-
65
- audio = whisperx.load_audio(str(media))
66
- model = whisperx.load_model(args.model, device="cpu", compute_type="int8", language=args.language)
67
- result = model.transcribe(audio, batch_size=4, language=args.language)
68
- language = result["language"]
69
-
70
- align_model, metadata = whisperx.load_align_model(language_code=language, device="cpu")
71
- aligned = whisperx.align(result["segments"], align_model, metadata, audio, device="cpu", return_char_alignments=False)
72
-
73
- words = []
74
- for segment in aligned["segments"]:
75
- for w in segment.get("words", []):
76
- words.append(
77
- {
78
- "word": w["word"],
79
- "start": round(w["start"], 3) if "start" in w else None,
80
- "end": round(w["end"], 3) if "end" in w else None,
81
- "score": round(w["score"], 3) if "score" in w else None,
82
- }
83
- )
84
-
85
- output = {
86
- "source": str(media),
87
- "language": language,
88
- "model": args.model,
89
- "durationSec": round(len(audio) / 16000, 3),
90
- "text": " ".join(s["text"].strip() for s in aligned["segments"]),
91
- "words": words,
92
- }
93
- out_file = out_dir / "words.json"
94
- out_file.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
95
-
96
- unplaced = [w["word"] for w in words if w["start"] is None]
97
- print(f"language: {language} | words: {len(words)} | unplaced: {len(unplaced)} {unplaced if unplaced else ''}")
98
- print(f"text: {output['text']}")
99
- print(f"wrote {out_file} in {time.time() - started:.0f}s")
100
-
101
-
102
- if __name__ == "__main__":
103
- main()
1
+ """Word-level transcript for a clip: WhisperX transcription + wav2vec2 forced alignment.
2
+
3
+ Usage (from the project root):
4
+ python scripts/transcribe.py <media> [--language en] [--model small]
5
+
6
+ Writes work/<clip-name>/words.json. Models are cached under the studio's .models folder.
7
+ Words the aligner cannot place (often digits like "186%") keep start/end = null and must be
8
+ fixed against the script before building the spine.
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import time
15
+ from pathlib import Path
16
+
17
+ import sys as _sys # noqa: E402
18
+ _sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
19
+ from media import FFMPEG, use_certifi # noqa: E402
20
+
21
+ # Models are large; cache them inside the studio so they survive and are easy to find.
22
+ _MODELS = Path(__file__).resolve().parent.parent / ".models"
23
+ os.environ.setdefault("HF_HOME", str(_MODELS / "huggingface"))
24
+ os.environ.setdefault("TORCH_HOME", str(_MODELS / "torch"))
25
+ os.environ["PATH"] = str(Path(FFMPEG).parent) + os.pathsep + os.environ["PATH"]
26
+
27
+ # torch.hub fetches the word-alignment model with urllib, which on a python.org macOS
28
+ # build has no root certificates — transcription then dies with CERTIFICATE_VERIFY_FAILED
29
+ # after the slow part has already succeeded. Must run before torch is imported.
30
+ use_certifi()
31
+
32
+ try:
33
+ import whisperx # noqa: E402 (must import after the cache/PATH/cert setup above)
34
+ except ModuleNotFoundError as exc: # pragma: no cover
35
+ # Run with the wrong interpreter and the bare error reads as "never installed", which
36
+ # sends you off to fix the wrong thing. Say which python is running and what to do.
37
+ _venv = Path(__file__).resolve().parent.parent / ".venv"
38
+ _venv_py = _venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python3")
39
+ print(f"Cannot import {exc.name}.", file=_sys.stderr)
40
+ print(f" running: {_sys.executable} (python {_sys.version.split()[0]})", file=_sys.stderr)
41
+ if _venv_py.exists():
42
+ print(f" the studio's environment is at {_venv_py}", file=_sys.stderr)
43
+ print(" run the transcriber with that python, or use: npm run prep", file=_sys.stderr)
44
+ else:
45
+ print(" the studio has no .venv yet. Build one with python 3.11 or 3.12:", file=_sys.stderr)
46
+ print(" py -3.12 -m venv .venv", file=_sys.stderr)
47
+ print(" .venv/Scripts/python.exe -m pip install -r requirements.txt", file=_sys.stderr)
48
+ print(" see what you have with: npm run doctor", file=_sys.stderr)
49
+ raise SystemExit(2)
50
+
51
+
52
+ def main() -> None:
53
+ parser = argparse.ArgumentParser()
54
+ parser.add_argument("media")
55
+ parser.add_argument("--language", default=None, help="e.g. en, hi. Detected when omitted.")
56
+ parser.add_argument("--project", default=None, help="Video project name: writes to work/<project>/<clip>/")
57
+ parser.add_argument("--model", default="small", help="Whisper size: small (fast on CPU) or medium (more accurate)")
58
+ args = parser.parse_args()
59
+
60
+ media = Path(args.media)
61
+ out_dir = (Path("work") / args.project / media.stem) if args.project else (Path("work") / media.stem)
62
+ out_dir.mkdir(parents=True, exist_ok=True)
63
+ started = time.time()
64
+
65
+ audio = whisperx.load_audio(str(media))
66
+ model = whisperx.load_model(args.model, device="cpu", compute_type="int8", language=args.language)
67
+ result = model.transcribe(audio, batch_size=4, language=args.language)
68
+ language = result["language"]
69
+
70
+ align_model, metadata = whisperx.load_align_model(language_code=language, device="cpu")
71
+ aligned = whisperx.align(result["segments"], align_model, metadata, audio, device="cpu", return_char_alignments=False)
72
+
73
+ words = []
74
+ for segment in aligned["segments"]:
75
+ for w in segment.get("words", []):
76
+ words.append(
77
+ {
78
+ "word": w["word"],
79
+ "start": round(w["start"], 3) if "start" in w else None,
80
+ "end": round(w["end"], 3) if "end" in w else None,
81
+ "score": round(w["score"], 3) if "score" in w else None,
82
+ }
83
+ )
84
+
85
+ output = {
86
+ "source": str(media),
87
+ "language": language,
88
+ "model": args.model,
89
+ "durationSec": round(len(audio) / 16000, 3),
90
+ "text": " ".join(s["text"].strip() for s in aligned["segments"]),
91
+ "words": words,
92
+ }
93
+ out_file = out_dir / "words.json"
94
+ out_file.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
95
+
96
+ unplaced = [w["word"] for w in words if w["start"] is None]
97
+ print(f"language: {language} | words: {len(words)} | unplaced: {len(unplaced)} {unplaced if unplaced else ''}")
98
+ print(f"text: {output['text']}")
99
+ print(f"wrote {out_file} in {time.time() - started:.0f}s")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import {useCurrentFrame, useVideoConfig} from 'remotion';
3
- import {COLOR, FONT, PALETTE, RADIUS, SHADOW, SPACE, TYPE, WEIGHT} from '../design/tokens';
3
+ import {withAlpha} from '../design/color';
4
+ import {COLOR, FONT, RADIUS, SHADOW, SPACE, TYPE, WEIGHT} from '../design/tokens';
4
5
  import {EASE} from '../design/motion';
5
6
  import {progress} from '../lib/animate';
6
7
 
@@ -38,8 +39,8 @@ export type Segment =
38
39
  | {kind: 'redacted'; text: string; label?: string};
39
40
 
40
41
  const COLOR_FOR: Record<Segment['kind'], string> = {
41
- plain: PALETTE.stone100,
42
- flag: PALETTE.stone400,
42
+ plain: COLOR.fgOnDark,
43
+ flag: COLOR.fgMuted,
43
44
  accent: COLOR.accent,
44
45
  redacted: 'transparent',
45
46
  };
@@ -59,8 +60,8 @@ const Redaction: React.FC<{width: number; label?: string}> = ({width, label}) =>
59
60
  verticalAlign: 'middle',
60
61
  borderRadius: RADIUS.sm / 2,
61
62
  // Accent at low alpha, not a grey smear: it reads as a deliberate mark rather than a mistake.
62
- backgroundColor: `${PALETTE.lime400}2e`,
63
- border: `2px solid ${PALETTE.lime400}66`,
63
+ backgroundColor: withAlpha(COLOR.accent, 0.18),
64
+ border: `2px solid ${withAlpha(COLOR.accent, 0.4)}`,
64
65
  }}
65
66
  >
66
67
  <span
@@ -113,7 +114,7 @@ export const CommandCard: React.FC<{
113
114
  width,
114
115
  maxWidth: '100%',
115
116
  borderRadius: RADIUS.md,
116
- backgroundColor: PALETTE.ink900,
117
+ backgroundColor: COLOR.surfaceDark,
117
118
  boxShadow: SHADOW.pop,
118
119
  overflow: 'hidden',
119
120
  opacity: land,
@@ -126,11 +127,11 @@ export const CommandCard: React.FC<{
126
127
  alignItems: 'center',
127
128
  gap: SPACE[2],
128
129
  padding: `${SPACE[2]}px ${SPACE[3]}px`,
129
- backgroundColor: PALETTE.ink800,
130
- borderBottom: `2px solid ${PALETTE.ink700}`,
130
+ backgroundColor: COLOR.borderStrong,
131
+ borderBottom: `2px solid ${withAlpha(COLOR.fgOnDark, 0.08)}`,
131
132
  }}
132
133
  >
133
- {[PALETTE.stone600, PALETTE.stone600, PALETTE.stone600].map((c, i) => (
134
+ {[COLOR.fgSecondary, COLOR.fgSecondary, COLOR.fgSecondary].map((c, i) => (
134
135
  <span key={i} style={{width: 14, height: 14, borderRadius: RADIUS.pill, backgroundColor: c}} />
135
136
  ))}
136
137
  <span style={{flex: 1}} />
@@ -141,7 +142,7 @@ export const CommandCard: React.FC<{
141
142
  fontWeight: WEIGHT.medium,
142
143
  letterSpacing: TYPE.eyebrow.tracking,
143
144
  textTransform: 'uppercase',
144
- color: note ? COLOR.accent : PALETTE.stone400,
145
+ color: note ? COLOR.accent : COLOR.fgMuted,
145
146
  }}
146
147
  >
147
148
  {note ?? (platform === 'mac' ? 'macOS' : platform === 'windows' ? 'Windows' : '')}
@@ -155,7 +156,7 @@ export const CommandCard: React.FC<{
155
156
  fontSize,
156
157
  lineHeight: 1.65,
157
158
  letterSpacing: '-0.01em',
158
- color: PALETTE.stone100,
159
+ color: COLOR.fgOnDark,
159
160
  wordBreak: 'break-word',
160
161
  }}
161
162
  >
@@ -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
+ };