@lalalic/markcut 2.3.0 → 2.4.1

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,5 +1,5 @@
1
1
  import * as React from "react";
2
- import { cancelRender, continueRender, delayRender, Sequence, staticFile, useVideoConfig } from "remotion";
2
+ import { cancelRender, continueRender, delayRender, Sequence, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
3
3
  import * as Subtitles from "remotion-subtitle";
4
4
  import { cssJS, parseVTT, type Cue } from "../utils/index";
5
5
  import type { SubtitleOverlay as SubtitleOverlayConfig } from "../schema/index";
@@ -89,28 +89,79 @@ function supportHtml(text: string): any {
89
89
  return el;
90
90
  }
91
91
 
92
+ /**
93
+ * Strip HTML tags from text, returning the plain text content.
94
+ * Used to compare cue texts ignoring word-level highlighting markup.
95
+ */
96
+ function stripHtml(text: string): string {
97
+ return text.replace(/<[^>]*>/g, "").trim();
98
+ }
99
+
100
+ /**
101
+ * Group consecutive cues that share the same plain-text content.
102
+ * This is the pattern used by word-level highlighting VTT where each
103
+ * cue contains the full sentence but wraps a different word in HTML
104
+ * (e.g. `<u>` or `<span style="...">`).
105
+ *
106
+ * Grouping them into a single CueFrame keeps the caption component
107
+ * mounted across word transitions, eliminating the flash that occurs
108
+ * when each cue unmounts/remounts its own Sequence.
109
+ */
110
+ function groupConsecutiveCues(cues: Cue[]): Cue[][] {
111
+ const groups: Cue[][] = [];
112
+ let current: Cue[] = [];
113
+
114
+ for (const cue of cues) {
115
+ const plain = stripHtml(cue.text);
116
+ if (current.length === 0) {
117
+ current.push(cue);
118
+ } else {
119
+ const prevPlain = stripHtml(current[current.length - 1]!.text);
120
+ if (plain === prevPlain) {
121
+ current.push(cue);
122
+ } else {
123
+ groups.push(current);
124
+ current = [cue];
125
+ }
126
+ }
127
+ }
128
+ if (current.length > 0) groups.push(current);
129
+ return groups;
130
+ }
131
+
92
132
  /**
93
133
  * Renders a single cue inside a <Sequence> so Remotion only evaluates it
94
134
  * during the cue's active time window — matching the qili-ai pattern.
95
135
  * This avoids per-frame cue lookups and lets the caption component mount
96
136
  * once per cue rather than re-creating on every frame.
137
+ *
138
+ * When a cue group has multiple entries (same plain text, different HTML
139
+ * highlighting), the component uses useCurrentFrame() to select the
140
+ * active cue's text dynamically — keeping the caption component mounted
141
+ * across word transitions and preventing flash.
97
142
  */
98
143
  function CueFrame({
99
144
  cue,
100
145
  fps,
101
146
  CaptionComponent,
102
147
  subtitle,
148
+ cueGroup,
103
149
  }: {
104
150
  cue: Cue;
105
151
  fps: number;
106
152
  CaptionComponent: React.FC<{ text: any; style?: React.CSSProperties }>;
107
153
  subtitle: SubtitleOverlayConfig;
154
+ /** Optional full group of cues sharing the same plain text. When provided,
155
+ * the Sequence spans the entire group and highlights change dynamically. */
156
+ cueGroup?: Cue[];
108
157
  }) {
109
- const durationInFrames = Math.max(1, Math.floor((cue.endAt - cue.startFrom) * fps));
110
- const from = Math.floor(cue.startFrom * fps);
158
+ const group = cueGroup ?? [cue];
159
+ const startFrom = group[0]!.startFrom;
160
+ const endAt = group[group.length - 1]!.endAt;
161
+
162
+ const durationInFrames = Math.max(1, Math.floor((endAt - startFrom) * fps));
163
+ const from = Math.floor(startFrom * fps);
111
164
 
112
- // Stable reference: compute once per cue, not every frame
113
- const captionText = React.useMemo(() => supportHtml(cue.text), [cue.text]);
114
165
  const textStyle: React.CSSProperties = React.useMemo(
115
166
  () => ({
116
167
  ...DEFAULT_TEXT_STYLE,
@@ -123,17 +174,56 @@ function CueFrame({
123
174
 
124
175
  return (
125
176
  <Sequence layout="none" durationInFrames={durationInFrames} from={from}>
126
- <CaptionComponent text={captionText} style={textStyle} />
177
+ <GroupedCueInner
178
+ group={group}
179
+ fps={fps}
180
+ CaptionComponent={CaptionComponent}
181
+ textStyle={textStyle}
182
+ />
127
183
  </Sequence>
128
184
  );
129
185
  }
130
186
 
187
+ /**
188
+ * Inner component that renders inside the single Sequence.
189
+ * Uses useCurrentFrame() to pick which cue's HTML text to display
190
+ * based on the current time, keeping the caption component mounted
191
+ * across word transitions.
192
+ */
193
+ function GroupedCueInner({
194
+ group,
195
+ fps,
196
+ CaptionComponent,
197
+ textStyle,
198
+ }: {
199
+ group: Cue[];
200
+ fps: number;
201
+ CaptionComponent: React.FC<{ text: any; style?: React.CSSProperties }>;
202
+ textStyle: React.CSSProperties;
203
+ }) {
204
+ const frame = useCurrentFrame();
205
+ const currentTime = frame / fps;
206
+
207
+ // Find the active cue based on current time
208
+ let activeCue = group[group.length - 1]!;
209
+ for (const c of group) {
210
+ if (currentTime >= c.startFrom && currentTime < c.endAt) {
211
+ activeCue = c;
212
+ break;
213
+ }
214
+ }
215
+
216
+ const captionText = React.useMemo(() => supportHtml(activeCue.text), [activeCue.text]);
217
+
218
+ return <CaptionComponent text={captionText} style={textStyle} />;
219
+ }
220
+
131
221
  /**
132
222
  * Root-level subtitle overlay. Renders a single VTT file as captions over
133
223
  * the entire video. VTT cue timestamps are absolute (seconds from video start),
134
224
  * matching useCurrentFrame() / fps directly.
135
225
  *
136
- * Each cue is rendered as a separate <Sequence> (qili-ai pattern) so Remotion
226
+ * Each cue is rendered as a separate <Sequence> so Remotion
137
227
  * only mounts/evaluates the caption component during its active time window.
138
228
  * This avoids per-frame computation for inactive cues.
139
229
  *
@@ -198,16 +288,21 @@ export function SubtitleOverlay({ subtitle }: { subtitle: SubtitleOverlayConfig
198
288
  };
199
289
  }, [subtitle.src]);
200
290
 
201
- if (!cues) return null;
291
+ if (!cues) return null;@
292
+
293
+ const boxCss = subtitle.style ? (cssJS(subtitle.style) as React.CSSProperties) : {};@
202
294
 
203
- const boxCss = subtitle.style ? (cssJS(subtitle.style) as React.CSSProperties) : {};
295
+ // Group consecutive cues with same plain text (word-level highlighting pattern)
296
+ // so the caption component stays mounted across word transitions — no flash.
297
+ const cueGroups = React.useMemo(() => groupConsecutiveCues(cues), [cues]);
204
298
 
205
299
  return (
206
300
  <div className={`${subtitle.type || "default"} subtitle-overlay`} style={{ ...DEFAULT_BOX_STYLE, ...boxCss }}>
207
- {cues.map((cue, i) => (
301
+ {cueGroups.map((group, gi) => (
208
302
  <CueFrame
209
- key={`${i}-${cue.startFrom}-${cue.endAt}`}
210
- cue={cue}
303
+ key={`g${gi}-${group[0]!.startFrom}-${group[group.length - 1]!.endAt}`}
304
+ cue={group[0]!}
305
+ cueGroup={group}
211
306
  fps={fps}
212
307
  CaptionComponent={CaptionComponent}
213
308
  subtitle={subtitle}
@@ -23,7 +23,7 @@ export function VideoLeaf({ stream }: { stream: Video }) {
23
23
  const startFrom = stream.startFrom ?? 0;
24
24
  const endAt = stream.endAt ?? totalDur;
25
25
  const volume = stream.volume ?? 1;
26
- const playbackRate = stream.loop ? 1 : Math.min(1, toPlaybackRate((endAt - startFrom) / (end - start)));
26
+ const playbackRate = Math.min(1, toPlaybackRate((endAt - startFrom) / (end - start)));
27
27
  const streamStyle = cssJS(stream.style);
28
28
  const hasAnimation = "animation" in streamStyle;
29
29
  return (
@@ -473,11 +473,11 @@ describe("Audio STT Verification", () => {
473
473
  });
474
474
 
475
475
  // ───────────────────────────────────────────────────────────────────────────
476
- // 11. Multiple Aspect Ratios
476
+ // 11. Dimensions from Stream File
477
477
  // ───────────────────────────────────────────────────────────────────────────
478
478
 
479
- describe("Multiple Aspect Ratios", () => {
480
- it("renders same fixture at 16x9, 9x16, and 1x1", async () => {
479
+ describe("Dimensions from Stream File", () => {
480
+ it("respects width/height defined in the stream tree root", async () => {
481
481
  const fixture = {
482
482
  root: {
483
483
  id: "root",
@@ -490,7 +490,7 @@ describe("Multiple Aspect Ratios", () => {
490
490
  {
491
491
  id: "bg",
492
492
  type: "image",
493
- src: "https://picsum.photos/seed/aspect-test/1920/1080",
493
+ src: "https://picsum.photos/seed/dim-test/1920/1080",
494
494
  fit: "cover",
495
495
  start: 0,
496
496
  end: 2,
@@ -498,7 +498,7 @@ describe("Multiple Aspect Ratios", () => {
498
498
  {
499
499
  id: "title",
500
500
  type: "image",
501
- src: "https://picsum.photos/seed/aspect-title/1920/1080",
501
+ src: "https://picsum.photos/seed/dim-title/1920/1080",
502
502
  fit: "cover",
503
503
  start: 0.3,
504
504
  end: 1.7,
@@ -508,18 +508,16 @@ describe("Multiple Aspect Ratios", () => {
508
508
  };
509
509
 
510
510
  const { writeFileSync } = await import("node:fs");
511
- const tmpFixture = outPath("_aspects.json");
511
+ const tmpFixture = outPath("_dims.json");
512
512
  writeFileSync(tmpFixture, JSON.stringify(fixture));
513
513
 
514
- // Render with different aspect ratios via --props adaptation
515
- // Note: Aspect adaptation happens in the CLI, so we test with the default
514
+ // Dimensions come from the stream file root, not from a CLI --aspect flag
516
515
  const output = renderFixture(tmpFixture, {
517
- outputName: "aspect-default.mp4",
516
+ outputName: "dims-default.mp4",
518
517
  timeout: RENDER_TIMEOUT,
519
518
  });
520
519
 
521
520
  const info = getVideoInfo(output);
522
- // Default aspect is whatever the fixture says (1920x1080)
523
521
  expect(info.width).toBe(1920);
524
522
  expect(info.height).toBe(1080);
525
523