@twick/visualizer 0.15.24 → 0.15.25

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twick/visualizer",
3
- "version": "0.15.24",
3
+ "version": "0.15.25",
4
4
  "license": "https://github.com/ncounterspecialist/twick/blob/main/LICENSE.md",
5
5
  "scripts": {
6
6
  "start": "twick editor --projectFile ./src/live.tsx",
@@ -23,19 +23,19 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@preact/signals": "^1.2.1",
26
- "@twick/2d": "^0.15.24",
27
- "@twick/core": "^0.15.24",
28
- "@twick/effects": "0.15.24",
29
- "@twick/media-utils": "0.15.24",
30
- "@twick/renderer": "^0.15.24",
31
- "@twick/vite-plugin": "^0.15.24",
26
+ "@twick/2d": "^0.15.25",
27
+ "@twick/core": "^0.15.25",
28
+ "@twick/effects": "0.15.25",
29
+ "@twick/media-utils": "0.15.25",
30
+ "@twick/renderer": "^0.15.25",
31
+ "@twick/vite-plugin": "^0.15.25",
32
32
  "crelt": "^1.0.6",
33
33
  "date-fns": "^4.1.0",
34
34
  "preact": "^10.19.2"
35
35
  },
36
36
  "devDependencies": {
37
- "@twick/cli": "^0.15.24",
38
- "@twick/ui": "^0.15.24",
37
+ "@twick/cli": "^0.15.25",
38
+ "@twick/ui": "^0.15.25",
39
39
  "typescript": "5.4.2",
40
40
  "typedoc": "^0.25.8",
41
41
  "typedoc-plugin-markdown": "^3.17.1",
@@ -26,11 +26,11 @@ export const highlightBgHandler: CaptionStyleHandler = {
26
26
  <Rect
27
27
  ref={bgContainerRef}
28
28
  fill={_color}
29
- width={textRef().width() + (captionProps.bgOffsetWidth ?? 30)}
30
- height={textRef().height() + (captionProps.bgOffsetHeight ?? 10)}
29
+ width={textRef().width() + (captionProps.bgOffsetWidth ?? 20)}
30
+ height={textRef().height() + (captionProps.bgOffsetHeight ?? 8)}
31
31
  margin={captionProps.bgMargin ?? [0, -5]}
32
32
  radius={captionProps.bgRadius ?? 10}
33
- padding={captionProps.bgPadding ?? [0, 15]}
33
+ padding={captionProps.bgPadding ?? [0, 10]}
34
34
  opacity={0}
35
35
  alignItems={"center"}
36
36
  justifyContent={"center"}
@@ -7,6 +7,7 @@ import { softBoxHandler } from "./soft-box.handler";
7
7
  import { lowerThirdHandler } from "./lower-third.handler";
8
8
  import { typewriterHandler } from "./typewriter.handler";
9
9
  import { karaokeHandler } from "./karaoke.handler";
10
+ import { karaokeWordHandler } from "./karaoke-word.handler";
10
11
  import { popScaleHandler } from "./pop-scale.handler";
11
12
 
12
13
  /**
@@ -22,6 +23,7 @@ export function registerCaptionStyles(): void {
22
23
  registerCaptionStyle(lowerThirdHandler);
23
24
  registerCaptionStyle(typewriterHandler);
24
25
  registerCaptionStyle(karaokeHandler);
26
+ registerCaptionStyle(karaokeWordHandler);
25
27
  registerCaptionStyle(popScaleHandler);
26
28
  }
27
29
 
@@ -0,0 +1,54 @@
1
+ import { all, Color, createRef, waitFor } from "@twick/core";
2
+ import { Txt } from "@twick/2d";
3
+ import { hexToRGB } from "../helpers/utils";
4
+ import type { CaptionStyleHandler, RenderWordsParams, AnimateWordsParams } from "./types";
5
+
6
+ const INACTIVE_OPACITY = 0.45;
7
+
8
+
9
+ export const karaokeWordHandler: CaptionStyleHandler = {
10
+ id: "karaoke-word",
11
+
12
+ renderWords({ containerRef, words, caption }) {
13
+ const captionProps = caption.props;
14
+
15
+ const refs: Array<{ textRef: ReturnType<typeof createRef<Txt>> }> = [];
16
+
17
+ for (const word of words) {
18
+ const textRef = createRef<Txt>();
19
+ containerRef().add(
20
+ <Txt
21
+ ref={textRef}
22
+ {...captionProps}
23
+ text={word.t}
24
+ opacity={INACTIVE_OPACITY}
25
+ />
26
+ );
27
+ refs.push({ textRef });
28
+ }
29
+
30
+ return { refs };
31
+ },
32
+ *animateWords({ words, refs, caption }) {
33
+ const textColor = caption.props.colors?.text;
34
+ const highlightColor = caption.props.colors?.highlight ?? textColor;
35
+ for (let i = 0; i < words.length; i++) {
36
+ // Turn the previous word back to base color and remove outline,
37
+ // so its fill is clearly visible and not dominated by stroke.
38
+ if (i > 0) {
39
+ yield* all(
40
+ refs.refs[i - 1].textRef().fill(textColor, 0),
41
+ refs.refs[i - 1].textRef().opacity(INACTIVE_OPACITY, 0)
42
+ );
43
+ }
44
+ // Highlight the current word using the highlight color.
45
+ // Keep its existing lineWidth/stroke so it feels “active”.
46
+ yield* all(
47
+ refs.refs[i].textRef().lineWidth(0, 0),
48
+ refs.refs[i].textRef().fill(highlightColor, 0),
49
+ refs.refs[i].textRef().opacity(1, 0)
50
+ );
51
+ yield* waitFor(Math.max(0, words[i].e - words[i].s));
52
+ }
53
+ },
54
+ };
@@ -1,20 +1,16 @@
1
- import { Color, createRef, waitFor } from "@twick/core";
1
+ import { all, Color, createRef, waitFor } from "@twick/core";
2
2
  import { Txt } from "@twick/2d";
3
3
  import { hexToRGB } from "../helpers/utils";
4
4
  import type { CaptionStyleHandler, RenderWordsParams, AnimateWordsParams } from "./types";
5
5
 
6
- const INACTIVE_OPACITY = 0.5;
6
+ const INACTIVE_OPACITY = 0.45;
7
7
 
8
- /**
9
- * Karaoke: all words visible; current word switches to highlight color (one Txt per word, animate fill).
10
- * Same ref/animate pattern as word_by_word; no overlay needed.
11
- */
12
8
  export const karaokeHandler: CaptionStyleHandler = {
13
9
  id: "karaoke",
14
10
 
15
11
  renderWords({ containerRef, words, caption }) {
16
12
  const captionProps = caption.props;
17
- const textColor = captionProps.colors?.text ?? "#ffffff";
13
+
18
14
  const refs: Array<{ textRef: ReturnType<typeof createRef<Txt>> }> = [];
19
15
 
20
16
  for (const word of words) {
@@ -23,7 +19,6 @@ export const karaokeHandler: CaptionStyleHandler = {
23
19
  <Txt
24
20
  ref={textRef}
25
21
  {...captionProps}
26
- fill={textColor}
27
22
  text={word.t}
28
23
  opacity={INACTIVE_OPACITY}
29
24
  />
@@ -35,24 +30,21 @@ export const karaokeHandler: CaptionStyleHandler = {
35
30
  },
36
31
 
37
32
  *animateWords({ words, refs, caption }) {
38
- const textColor = caption.props.colors?.text ?? "#ffffff";
39
- const highlightColor = caption.props.colors?.highlight ?? "#ff4081";
40
- const textColorObj = new Color({ ...hexToRGB(textColor), a: 1 });
41
- const highlightColorObj = new Color({ ...hexToRGB(highlightColor), a: 1 });
42
- const n = refs.refs.length;
43
-
33
+ const textColor = caption.props.colors?.text;
34
+ const highlightColor = caption.props.colors?.highlight ?? textColor;
44
35
  for (let i = 0; i < words.length; i++) {
45
- for (let j = 0; j < n; j++) {
46
- refs.refs[j].textRef().opacity(INACTIVE_OPACITY);
47
- refs.refs[j].textRef().fill(textColorObj);
36
+ if (i > 0) {
37
+ yield* all(
38
+ refs.refs[i - 1].textRef().lineWidth(0, 0),
39
+ refs.refs[i - 1].textRef().fill(textColor, 0),
40
+ );
48
41
  }
49
- refs.refs[i].textRef().opacity(1);
50
- refs.refs[i].textRef().fill(highlightColorObj, 0);
42
+ yield* all(
43
+ refs.refs[i].textRef().lineWidth(0, 0),
44
+ refs.refs[i].textRef().fill(highlightColor, 0),
45
+ refs.refs[i].textRef().opacity(1, 0)
46
+ );
51
47
  yield* waitFor(Math.max(0, words[i].e - words[i].s));
52
48
  }
53
- for (let j = 0; j < n; j++) {
54
- refs.refs[j].textRef().opacity(INACTIVE_OPACITY);
55
- refs.refs[j].textRef().fill(textColorObj);
56
- }
57
49
  },
58
50
  };
@@ -1,4 +1,4 @@
1
- import { Color, createRef, waitFor } from "@twick/core";
1
+ import { all, Color, createRef, waitFor } from "@twick/core";
2
2
  import { Txt } from "@twick/2d";
3
3
  import { hexToRGB } from "../helpers/utils";
4
4
  import type { CaptionStyleHandler, RenderWordsParams, AnimateWordsParams } from "./types";
@@ -25,9 +25,17 @@ export const lowerThirdHandler: CaptionStyleHandler = {
25
25
  return { refs };
26
26
  },
27
27
 
28
- *animateWords({ words, refs }) {
28
+ *animateWords({ words, refs, caption }) {
29
+ const textColor = caption.props.colors?.text;
30
+ const highlightColor = caption.props.colors?.highlight ?? textColor;
29
31
  for (let i = 0; i < words.length; i++) {
30
- yield* refs.refs[i].textRef().opacity(1, 0);
32
+ if (i > 0) {
33
+ yield* all(refs.refs[i-1].textRef().lineWidth(0, 0), refs.refs[i-1].textRef().fill(textColor, 0));
34
+
35
+ }
36
+ yield* all(
37
+ refs.refs[i].textRef().fill(highlightColor, 0),
38
+ refs.refs[i].textRef().opacity(1, 0));
31
39
  yield* waitFor(Math.max(0, words[i].e - words[i].s));
32
40
  }
33
41
  },
@@ -9,10 +9,11 @@ export const outlineOnlyHandler: CaptionStyleHandler = {
9
9
  const captionProps = caption.props;
10
10
  const refs: Array<{ textRef: ReturnType<typeof createRef<Txt>> }> = [];
11
11
 
12
+
12
13
  for (const word of words) {
13
14
  const textRef = createRef<Txt>();
14
15
  containerRef().add(
15
- <Txt ref={textRef} {...captionProps} text={word.t} opacity={0} />
16
+ <Txt ref={textRef} {...captionProps} text={word.t} opacity={1} lineWidth={0}/>
16
17
  );
17
18
  refs.push({ textRef });
18
19
  }
@@ -20,9 +21,12 @@ export const outlineOnlyHandler: CaptionStyleHandler = {
20
21
  return { refs };
21
22
  },
22
23
 
23
- *animateWords({ words, refs }) {
24
+ *animateWords({ words, refs, caption }) {
24
25
  for (let i = 0; i < words.length; i++) {
25
- yield* refs.refs[i].textRef().opacity(1, 0);
26
+ if(i > 0) {
27
+ yield* refs.refs[i-1].textRef().lineWidth(0, 0);
28
+ }
29
+ yield* refs.refs[i].textRef().lineWidth((caption.props.lineWidth ?? 0.4)*5, 0);
26
30
  yield* waitFor(Math.max(0, words[i].e - words[i].s));
27
31
  }
28
32
  },
@@ -1,8 +1,8 @@
1
- import { createRef, waitFor } from "@twick/core";
1
+ import { all, createRef, waitFor } from "@twick/core";
2
2
  import { Txt } from "@twick/2d";
3
3
  import type { CaptionStyleHandler, RenderWordsParams, AnimateWordsParams } from "./types";
4
4
 
5
- const POP_DURATION = 0.2;
5
+ const POP_DURATION = 0.025;
6
6
 
7
7
  /**
8
8
  * Pop: words pop in with a quick opacity fade (same ref/animate pattern as word_by_word).
@@ -26,9 +26,14 @@ export const popScaleHandler: CaptionStyleHandler = {
26
26
  return { refs };
27
27
  },
28
28
 
29
- *animateWords({ words, refs }) {
29
+ *animateWords({ words, refs, caption }) {
30
+ const textColor = caption.props.colors?.text;
31
+ const highlightColor = caption.props.colors?.highlight ?? textColor;
30
32
  for (let i = 0; i < words.length; i++) {
31
- yield* refs.refs[i].textRef().opacity(1, POP_DURATION);
33
+ if (i > 0) {
34
+ yield* all(refs.refs[i - 1].textRef().scale(0.95, 0), refs.refs[i - 1].textRef().lineWidth(0, 0), refs.refs[i-1].textRef().fill(textColor, 0))
35
+ }
36
+ yield* all(refs.refs[i].textRef().opacity(1, POP_DURATION), refs.refs[i].textRef().fill(highlightColor, 0), refs.refs[i].textRef().lineWidth(0, 0), refs.refs[i].textRef().scale(1.15, POP_DURATION));
32
37
  yield* waitFor(Math.max(0, words[i].e - words[i].s - POP_DURATION));
33
38
  }
34
39
  },
@@ -1,4 +1,4 @@
1
- import { Color, createRef, waitFor } from "@twick/core";
1
+ import { all, Color, createRef, waitFor } from "@twick/core";
2
2
  import { Txt } from "@twick/2d";
3
3
  import { hexToRGB } from "../helpers/utils";
4
4
  import type { CaptionStyleHandler, RenderWordsParams, AnimateWordsParams } from "./types";
@@ -21,9 +21,17 @@ export const softBoxHandler: CaptionStyleHandler = {
21
21
  return { refs };
22
22
  },
23
23
 
24
- *animateWords({ words, refs }) {
24
+ *animateWords({ words, refs, caption }) {
25
+ const textColor = caption.props.colors?.text;
26
+ const highlightColor = caption.props.colors?.highlight ?? textColor;
25
27
  for (let i = 0; i < words.length; i++) {
26
- yield* refs.refs[i].textRef().opacity(1, 0);
28
+ if (i > 0) {
29
+ yield* all(refs.refs[i-1].textRef().lineWidth(0, 0), refs.refs[i-1].textRef().fill(textColor, 0));
30
+
31
+ }
32
+ yield* all(
33
+ refs.refs[i].textRef().fill(highlightColor, 0),
34
+ refs.refs[i].textRef().opacity(1, 0));
27
35
  yield* waitFor(Math.max(0, words[i].e - words[i].s));
28
36
  }
29
37
  },
@@ -1,33 +1,79 @@
1
1
  import { createRef, waitFor } from "@twick/core";
2
2
  import { Txt } from "@twick/2d";
3
- import type { CaptionStyleHandler, RenderWordsParams, AnimateWordsParams } from "./types";
3
+ import type {
4
+ CaptionStyleHandler,
5
+ RenderWordsParams,
6
+ AnimateWordsParams,
7
+ } from "./types";
4
8
 
5
9
  /**
6
- * Typewriter: words appear one by one (same pattern as word_by_word).
7
- * Letter-by-letter would require a wrapper per word so prefixes overlay; follow highlight-bg structure if adding later.
10
+ * Typewriter: phrase-level, letter-by-letter typing effect.
11
+ * Mirrors the behavior of text-effects/TypewriterEffect but scoped to a
12
+ * single caption phrase. All words in the phrase are rendered as one
13
+ * continuous string and revealed character by character over the phrase
14
+ * duration.
8
15
  */
9
16
  export const typewriterHandler: CaptionStyleHandler = {
10
17
  id: "typewriter",
11
18
 
12
- renderWords({ containerRef, words, caption }) {
19
+ renderWords({ containerRef, words, caption }: RenderWordsParams) {
13
20
  const captionProps = caption.props;
14
- const refs: Array<{ textRef: ReturnType<typeof createRef<Txt>> }> = [];
15
-
16
- for (const word of words) {
17
- const textRef = createRef<Txt>();
18
- containerRef().add(
19
- <Txt ref={textRef} {...captionProps} text={word.t} opacity={0} />
20
- );
21
- refs.push({ textRef });
22
- }
21
+ const phraseText =
22
+ caption.t && caption.t.length > 0
23
+ ? caption.t
24
+ : words.map((w) => w.t).join(" ");
25
+
26
+ const textRef = createRef<Txt>();
27
+ containerRef().add(
28
+ <Txt ref={textRef} {...captionProps} text={phraseText} />
29
+ );
23
30
 
24
- return { refs };
31
+ return { refs: [{ textRef }] };
25
32
  },
26
33
 
27
- *animateWords({ words, refs }) {
28
- for (let i = 0; i < words.length; i++) {
29
- yield* refs.refs[i].textRef().opacity(1, 0);
30
- yield* waitFor(Math.max(0, words[i].e - words[i].s));
34
+ *animateWords({ words, refs }: AnimateWordsParams) {
35
+ if (!refs.refs.length || !words.length) {
36
+ return;
37
+ }
38
+
39
+ const textRef = refs.refs[0].textRef;
40
+ const fullText = textRef().text() ?? "";
41
+
42
+ if (!fullText.length) {
43
+ // Nothing to animate; just keep the text as-is.
44
+ return;
45
+ }
46
+
47
+ // Compute total phrase duration from word timings.
48
+ const phraseStart = words[0].s;
49
+ const phraseEnd = words[words.length - 1].e;
50
+ const totalDuration = Math.max(0, phraseEnd - phraseStart);
51
+
52
+ if (totalDuration <= 0) {
53
+ textRef().text(fullText);
54
+ return;
55
+ }
56
+
57
+ // Preserve original size to avoid layout shifts while typing.
58
+ const size = textRef().size();
59
+ textRef().text("");
60
+ textRef().size(size);
61
+
62
+ // Left-align for a more natural typing feel.
63
+ textRef().textAlign("left");
64
+
65
+ // Reserve a small buffer at the end so the final state is visible.
66
+ const bufferTime = Math.min(totalDuration * 0.1, 0.5);
67
+ const effectiveDuration = Math.max(0.001, totalDuration - bufferTime);
68
+ const interval = effectiveDuration / fullText.length;
69
+
70
+ // Small initial pause before the first character.
71
+ yield* waitFor(interval);
72
+
73
+ // Reveal each character sequentially.
74
+ for (let i = 0; i < fullText.length; i++) {
75
+ yield* waitFor(interval);
76
+ textRef().text(fullText.substring(0, i + 1));
31
77
  }
32
78
  },
33
79
  };
@@ -1,4 +1,4 @@
1
- import { Color, createRef, waitFor } from "@twick/core";
1
+ import { all, Color, createRef, waitFor } from "@twick/core";
2
2
  import { Txt } from "@twick/2d";
3
3
  import { hexToRGB } from "../helpers/utils";
4
4
  import type { CaptionStyleHandler, RenderWordsParams, AnimateWordsParams } from "./types";
@@ -21,9 +21,17 @@ export const wordByWordWithBgHandler: CaptionStyleHandler = {
21
21
  return { refs };
22
22
  },
23
23
 
24
- *animateWords({ words, refs }) {
24
+ *animateWords({ words, refs, caption }) {
25
+ const textColor = caption.props.colors?.text;
26
+ const highlightColor = caption.props.colors?.highlight ?? textColor;
25
27
  for (let i = 0; i < words.length; i++) {
26
- yield* refs.refs[i].textRef().opacity(1, 0);
28
+ if (i > 0) {
29
+ yield* all(refs.refs[i-1].textRef().lineWidth(0, 0), refs.refs[i-1].textRef().fill(textColor, 0));
30
+
31
+ }
32
+ yield* all(
33
+ refs.refs[i].textRef().fill(highlightColor, 0),
34
+ refs.refs[i].textRef().opacity(1, 0));
27
35
  yield* waitFor(Math.max(0, words[i].e - words[i].s));
28
36
  }
29
37
  },
@@ -1,4 +1,4 @@
1
- import { createRef, waitFor } from "@twick/core";
1
+ import { all, createRef, useLogger, waitFor } from "@twick/core";
2
2
  import { Txt } from "@twick/2d";
3
3
  import type { CaptionStyleHandler, RenderWordsParams, AnimateWordsParams } from "./types";
4
4
 
@@ -20,9 +20,17 @@ export const wordByWordHandler: CaptionStyleHandler = {
20
20
  return { refs };
21
21
  },
22
22
 
23
- *animateWords({ words, refs }) {
23
+ *animateWords({ words, refs, caption }) {
24
+ const textColor = caption.props.colors?.text;
25
+ const highlightColor = caption.props.colors?.highlight ?? textColor;
24
26
  for (let i = 0; i < words.length; i++) {
25
- yield* refs.refs[i].textRef().opacity(1, 0);
27
+ if (i > 0) {
28
+ yield* all(refs.refs[i-1].textRef().lineWidth(0, 0), refs.refs[i-1].textRef().fill(textColor, 0));
29
+
30
+ }
31
+ yield* all(
32
+ refs.refs[i].textRef().fill(highlightColor, 0),
33
+ refs.refs[i].textRef().opacity(1, 0));
26
34
  yield* waitFor(Math.max(0, words[i].e - words[i].s));
27
35
  }
28
36
  },
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { Layout, Rect, View2D, Audio, Img, Txt } from "@twick/2d";
7
7
  import { VisualizerTrack, WatermarkInput } from "../helpers/types";
8
- import { all, createRef, ThreadGenerator, waitFor } from "@twick/core";
8
+ import { all, createRef, ThreadGenerator, useLogger, waitFor } from "@twick/core";
9
9
  import {
10
10
  CAPTION_STYLE,
11
11
  DEFAULT_CAPTION_COLORS,
@@ -89,74 +89,93 @@ export function* makeCaptionTrack({
89
89
  track: VisualizerTrack;
90
90
  }) {
91
91
  let prevTime = 0;
92
+
92
93
  const captionTrackRef = createRef<any>();
93
94
  view.add(<Layout size={"100%"} ref={captionTrackRef} />);
94
95
 
95
96
  const tProps = track?.props;
96
97
  const defaultCapStyle = "highlight_bg";
98
+
99
+ for (const element of track.elements) {
100
+ const eProps = element.props ?? {};
101
+ // wordsMs belongs to caption timing only – keep it out of visual style merging.
102
+ const { wordsMs, ...elementPropsWithoutWords } = eProps as any;
97
103
 
98
- const applyToAll = tProps?.applyToAll ?? false;
99
-
100
- const trackDefaultProps =
101
- (CAPTION_STYLE[tProps?.capStyle ?? defaultCapStyle] || CAPTION_STYLE[defaultCapStyle] || {}).word || {};
104
+ const resolvedCapStyle =
105
+ (elementPropsWithoutWords as any)?.capStyle ?? tProps?.capStyle ?? defaultCapStyle;
102
106
 
103
- for (const element of track.elements) {
104
- const eProps = element.props;
105
- const resolvedCapStyle = eProps?.capStyle ?? tProps?.capStyle ?? defaultCapStyle;
106
- const rectStyle =
107
- (CAPTION_STYLE[resolvedCapStyle] || CAPTION_STYLE[defaultCapStyle] || {}).rect ||
108
- {};
107
+ const styleConfig = CAPTION_STYLE[resolvedCapStyle] || CAPTION_STYLE[defaultCapStyle] || {};
108
+ const trackDefaultProps = (styleConfig as any).word || {};
109
+ const rectStyle = (styleConfig as any).rect || {};
109
110
  // Cast alignItems/justifyContent as any to satisfy RectProps
110
111
  const mappedRectStyle = {
111
112
  ...rectStyle,
112
113
  justifyContent: rectStyle.justifyContent as any,
113
114
  alignItems: rectStyle.alignItems as any,
115
+ ...(tProps?.rectProps ?? {}),
114
116
  };
115
117
 
116
- const phraseColors = applyToAll ? tProps?.colors : eProps?.colors ?? tProps?.colors ?? DEFAULT_CAPTION_COLORS;
118
+ // Resolve colors with priority: element.props.colors > track.props.colors > defaults.
119
+ const elementColors = (elementPropsWithoutWords as any)?.colors;
120
+ const trackColors = tProps?.colors;
121
+ const phraseColors = elementColors ?? trackColors ?? DEFAULT_CAPTION_COLORS;
117
122
 
118
- const resolvedFont = applyToAll ? tProps?.font : eProps?.font ?? tProps?.font ?? DEFAULT_CAPTION_FONT;
119
- const defaults = trackDefaultProps as { fontFamily?: string; fontSize?: number; fontWeight?: number };
120
- const phraseProps = {
123
+ // Resolve font with priority: element.props.font > track.props.font > defaults.
124
+ const elementFont = (elementPropsWithoutWords as any)?.font;
125
+ const trackFont = tProps?.font;
126
+ const resolvedFont = elementFont ?? trackFont ?? DEFAULT_CAPTION_FONT;
127
+
128
+
129
+ const basePhraseStyle = {
121
130
  ...trackDefaultProps,
122
- ...(tProps?.captionProps || {}),
131
+ ...tProps,
132
+ ...elementPropsWithoutWords,
133
+ };
134
+
135
+ const bgOpacityFromBase = (basePhraseStyle as any)?.bgOpacity;
136
+
137
+ const phraseProps = {
138
+ ...basePhraseStyle,
123
139
  colors: phraseColors,
140
+ stroke: phraseColors.outlineColor,
124
141
  font: resolvedFont,
125
- fontFamily: resolvedFont?.family ?? defaults?.fontFamily ?? DEFAULT_CAPTION_FONT.family,
126
- fontSize: resolvedFont?.size ?? defaults?.fontSize ?? DEFAULT_CAPTION_FONT.size,
127
- fontWeight: resolvedFont?.weight ?? defaults?.fontWeight ?? DEFAULT_CAPTION_FONT.weight,
142
+ fontFamily:
143
+ resolvedFont.family,
144
+ fontSize:
145
+ resolvedFont.size,
146
+ fontWeight:
147
+ resolvedFont.weight,
128
148
  fill: phraseColors.text,
129
149
  bgColor: phraseColors.bgColor,
130
- bgOpacity: tProps?.bgOpacity ?? 1,
150
+ bgOpacity: bgOpacityFromBase ?? tProps?.bgOpacity ?? 1,
131
151
  };
132
152
 
133
153
  yield* waitFor(element?.s - prevTime);
134
154
  const phraseRef = createRef<any>();
155
+ const rectProps = tProps?.rectProps ?? {};
135
156
  captionTrackRef().add(
136
157
  <Rect
137
158
  ref={phraseRef}
138
159
  key={element.id}
139
160
  {...mappedRectStyle}
140
- x={applyToAll ? tProps?.x : eProps?.x ?? tProps?.x}
141
- y={applyToAll ? tProps?.y : eProps?.y ?? tProps?.y}
161
+ x={basePhraseStyle.x}
162
+ y={basePhraseStyle.y}
142
163
  layout
143
164
  />
144
165
  );
145
- // Allow styles to tweak how phrase-level props are interpreted.
146
- // For classic outline, use explicit outlineColor so it is independent
147
- // from per-word highlight color.
148
- const styledPhraseProps = {
166
+
167
+ // Ensure timing metadata (wordsMs) is preserved on the caption props
168
+ // while keeping it out of the visual style merging above.
169
+ const captionPropsForElement = {
149
170
  ...phraseProps,
150
- ...(resolvedCapStyle === "outline_only" && phraseColors?.outlineColor
151
- ? { stroke: phraseColors.outlineColor }
152
- : {}),
171
+ ...(wordsMs ? { wordsMs } : {}),
153
172
  };
154
173
 
155
174
  const styleHandler = getCaptionStyleHandler(resolvedCapStyle ?? "");
156
175
  if (styleHandler?.preparePhraseContainer) {
157
176
  styleHandler.preparePhraseContainer({
158
177
  phraseRef,
159
- phraseProps: styledPhraseProps,
178
+ phraseProps,
160
179
  });
161
180
  }
162
181
  yield* elementController.get("caption")?.create({
@@ -165,7 +184,7 @@ export function* makeCaptionTrack({
165
184
  ...element,
166
185
  t: element.t ?? "",
167
186
  capStyle: eProps?.capStyle ?? tProps?.capStyle,
168
- props: styledPhraseProps,
187
+ props: captionPropsForElement,
169
188
  },
170
189
  view,
171
190
  });
@@ -3,6 +3,43 @@ import { ThreadGenerator } from "@twick/core";
3
3
  import { splitPhraseTiming } from "../helpers/caption.utils";
4
4
  import { getCaptionStyleHandler, getDefaultCaptionStyleHandler } from "../caption-styles";
5
5
 
6
+ function computeWordTimings(caption: ElementParams["caption"]) {
7
+ if (!caption) {
8
+ return [];
9
+ }
10
+
11
+ const text = caption.t ?? "";
12
+ const baseWords = text.split(" ").filter((w) => w.length > 0);
13
+ if (!baseWords.length) {
14
+ return [];
15
+ }
16
+
17
+ // wordsMs is stored in milliseconds (absolute along the media timeline).
18
+ const wordsMs: number[] | undefined = (caption as any)?.props?.wordsMs;
19
+
20
+ if (!Array.isArray(wordsMs) || wordsMs.length < baseWords.length) {
21
+ // Fallback: proportional split across phrase duration.
22
+ return splitPhraseTiming(caption);
23
+ }
24
+
25
+ const phraseEndSec = caption.e;
26
+
27
+ return baseWords.map((word, index) => {
28
+ const startMs = wordsMs[index];
29
+ const endMs =
30
+ index + 1 < wordsMs.length ? wordsMs[index + 1] : phraseEndSec * 1000;
31
+
32
+ const startSec = startMs / 1000;
33
+ const endSec = endMs / 1000;
34
+
35
+ return {
36
+ t: word,
37
+ s: startSec,
38
+ e: endSec,
39
+ };
40
+ });
41
+ }
42
+
6
43
  /**
7
44
  * @group CaptionElement
8
45
  * CaptionElement creates and manages styled text overlays in the visualizer scene.
@@ -15,7 +52,7 @@ export const CaptionElement = {
15
52
  name: "caption",
16
53
 
17
54
  *create({ containerRef, caption, containerProps }: ElementParams): ThreadGenerator {
18
- const words = splitPhraseTiming(caption);
55
+ const words = computeWordTimings(caption);
19
56
  if (!words?.length) return;
20
57
 
21
58
  const handler =