@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.
- package/AGENTS.md +5 -5
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +5 -1
- package/skills/markcut/docs/markdown-descriptive.md +16 -7
- package/src/config.mjs +2 -1
- package/src/descriptive/compiler.ts +9 -5
- package/src/descriptive/markdown.ts +3 -1
- package/src/descriptive/resolve.ts +45 -17
- package/src/player/browser.tsx +32 -13
- package/src/player/bundle/player.js +93 -35
- package/src/player/components/SceneThumbnails.tsx +5 -2
- package/src/player/pipeline.mjs +12 -8
- package/src/player/pipeline.ts +4 -0
- package/src/player/server-shared.mjs +15 -6
- package/src/render/cli.mjs +67 -32
- package/src/schema/index.ts +0 -2
- package/src/types/Audio.tsx +1 -2
- package/src/types/Include.tsx +3 -11
- package/src/types/Subtitle.tsx +107 -12
- package/src/types/Video.tsx +1 -1
- package/tests/render.test.ts +8 -10
package/src/types/Subtitle.tsx
CHANGED
|
@@ -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
|
|
110
|
-
const
|
|
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
|
-
<
|
|
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>
|
|
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
|
-
|
|
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
|
-
{
|
|
301
|
+
{cueGroups.map((group, gi) => (
|
|
208
302
|
<CueFrame
|
|
209
|
-
key={
|
|
210
|
-
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}
|
package/src/types/Video.tsx
CHANGED
|
@@ -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 =
|
|
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 (
|
package/tests/render.test.ts
CHANGED
|
@@ -473,11 +473,11 @@ describe("Audio STT Verification", () => {
|
|
|
473
473
|
});
|
|
474
474
|
|
|
475
475
|
// ───────────────────────────────────────────────────────────────────────────
|
|
476
|
-
// 11.
|
|
476
|
+
// 11. Dimensions from Stream File
|
|
477
477
|
// ───────────────────────────────────────────────────────────────────────────
|
|
478
478
|
|
|
479
|
-
describe("
|
|
480
|
-
it("
|
|
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/
|
|
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/
|
|
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("
|
|
511
|
+
const tmpFixture = outPath("_dims.json");
|
|
512
512
|
writeFileSync(tmpFixture, JSON.stringify(fixture));
|
|
513
513
|
|
|
514
|
-
//
|
|
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: "
|
|
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
|
|