Skip to content

Commit 7e81b79

Browse files
Improve perfr of react rerenders (#36)
1 parent 6b4b5ca commit 7e81b79

21 files changed

+706
-175
lines changed

src/Main.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,28 @@ Sentry.init({
3535
enabled: process.env.NODE_ENV !== 'development',
3636
replaysSessionSampleRate: 0.05,
3737
replaysOnErrorSampleRate: 1.0,
38+
ignoreErrors: [
39+
// Browser extension errors (password managers, etc.)
40+
"ControlLooksLikePasswordCredentialField",
41+
"Cannot read properties of null (reading 'ControlLooksLikePasswordCredentialField')",
42+
// Other common extension-related errors
43+
"ResizeObserver loop limit exceeded",
44+
"ResizeObserver loop completed with undelivered notifications",
45+
// Extensions injecting scripts
46+
/^chrome-extension:\/\//,
47+
/^moz-extension:\/\//,
48+
],
49+
beforeSend(event) {
50+
// Filter out errors from browser extensions
51+
if (event.exception?.values?.[0]?.stacktrace?.frames?.some(
52+
(frame) => frame.filename?.includes("extension") ||
53+
frame.filename?.startsWith("chrome-extension://") ||
54+
frame.filename?.startsWith("moz-extension://")
55+
)) {
56+
return null;
57+
}
58+
return event;
59+
},
3860
});
3961

4062
const rootElement = document.querySelector("#root");

src/Player.res.mjs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Utils.gen.tsx

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Utils.res

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,30 @@ module Path = {
113113
}
114114
}
115115

116+
module TextUtils = {
117+
/**
118+
* Trims only dots and commas from the start and end of each word.
119+
* Preserves all other punctuation like ?, !, apostrophes, etc.
120+
* Example: "Hello, world..." -> "Hello world"
121+
* Example: "What's up?" -> "What's up?"
122+
* Example: "don't stop!" -> "don't stop!"
123+
*/
124+
@genType
125+
let stripPunctuation = (text: string): string => {
126+
text
127+
->String.splitByRegExp(%re("/\s+/"))
128+
->Core__Array.filterMap(word => word)
129+
->Core__Array.map(word => {
130+
// Trim dots and commas from start and end only
131+
word
132+
->String.replaceRegExp(%re("/^[.,]+/g"), "")
133+
->String.replaceRegExp(%re("/[.,]+$/g"), "")
134+
})
135+
->Core__Array.filter(word => String.length(word) > 0)
136+
->Core__Array.join(" ")
137+
}
138+
}
139+
116140
module Bool = {
117141
@inline
118142
let invert = a => !a

src/Utils.res.mjs

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/codecs/active-word.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,48 @@ export function calculateTotalHeight(
146146
: lineHeight;
147147
}
148148

149+
/**
150+
* Calculate the actual width of text based on word positions (widest line)
151+
*/
152+
export function calculateActualWidth(
153+
positions: WordPosition[],
154+
fontSizePx: number
155+
): number {
156+
if (positions.length === 0) return 0;
157+
158+
const lineHeight = fontSizePx * 1.2;
159+
160+
// Group positions by line (same y value within tolerance)
161+
const lineWidths: number[] = [];
162+
let currentLineY = positions[0].y;
163+
let currentLineMaxX = 0;
164+
let currentLineMinX = Infinity;
165+
166+
for (const pos of positions) {
167+
// Check if this is a new line (y position changed significantly)
168+
if (Math.abs(pos.y - currentLineY) > lineHeight * 0.5) {
169+
// Save previous line's width
170+
if (currentLineMinX !== Infinity) {
171+
lineWidths.push(currentLineMaxX - currentLineMinX);
172+
}
173+
// Start new line
174+
currentLineY = pos.y;
175+
currentLineMinX = pos.x;
176+
currentLineMaxX = pos.x + pos.width;
177+
} else {
178+
currentLineMinX = Math.min(currentLineMinX, pos.x);
179+
currentLineMaxX = Math.max(currentLineMaxX, pos.x + pos.width);
180+
}
181+
}
182+
183+
// Don't forget the last line
184+
if (currentLineMinX !== Infinity) {
185+
lineWidths.push(currentLineMaxX - currentLineMinX);
186+
}
187+
188+
return lineWidths.length > 0 ? Math.max(...lineWidths) : 0;
189+
}
190+
149191
export function interpolateBackgroundPosition(
150192
activePos: { x: number; y: number; width: number },
151193
prevPos: { x: number; y: number; width: number } | null | undefined,

src/codecs/render-worker.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,11 @@ export async function render({
746746

747747
try {
748748
for await (const wrappedCanvas of canvasSink.canvases()) {
749-
const { canvas: sourceCanvas, timestamp, duration } = wrappedCanvas;
749+
const { canvas: sourceCanvas, timestamp: rawTimestamp, duration } = wrappedCanvas;
750+
751+
// Clamp negative timestamps to 0 - some videos have frames with slightly
752+
// negative PTS values due to B-frames or encoding artifacts
753+
const timestamp = Math.max(0, rawTimestamp);
750754

751755
try {
752756
renderCueOnCanvas(cues, sourceCanvas, rendererCtx, timestamp);

src/codecs/subtitle-renderer.ts

Lines changed: 93 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "../screens/editor/Subtitles.gen";
66
import { style } from "../screens/editor/Style.gen";
77
import { wordChunk } from "../screens/editor/WordTimestamps.gen";
8+
import { TextUtils_stripPunctuation as stripPunctuation } from "../Utils.gen";
89
import KonvaCore from "konva/lib/Core";
910
import type Konva from "konva";
1011
import { Text } from "konva/lib/shapes/Text";
@@ -16,6 +17,7 @@ import {
1617
calculatePopScale,
1718
calculateWordPositions,
1819
calculateTotalHeight,
20+
calculateActualWidth,
1921
interpolateBackgroundPosition,
2022
calculateScaledBackground,
2123
} from "./active-word";
@@ -26,6 +28,51 @@ export type WordAnimationData = {
2628
cueRanges: Array<[number, number]>;
2729
};
2830

31+
function calculateActualTextDimensions(
32+
text: string,
33+
style: style,
34+
measureText: (text: string) => number
35+
): { width: number; height: number } {
36+
if (!text.trim()) {
37+
return { width: 0, height: 0 };
38+
}
39+
40+
const blockWidth = style.blockSize.width;
41+
const lineHeight = style.fontSizePx * 1.2;
42+
43+
// Split text into words and calculate line wrapping
44+
const words = text.trim().split(/\s+/);
45+
const lines: string[] = [];
46+
let currentLine = "";
47+
48+
for (const word of words) {
49+
const testLine = currentLine ? `${currentLine} ${word}` : word;
50+
const testWidth = measureText(testLine);
51+
52+
if (testWidth > blockWidth && currentLine) {
53+
lines.push(currentLine);
54+
currentLine = word;
55+
} else {
56+
currentLine = testLine;
57+
}
58+
}
59+
if (currentLine) {
60+
lines.push(currentLine);
61+
}
62+
63+
// Find the widest line
64+
let maxWidth = 0;
65+
for (const line of lines) {
66+
const lineWidth = measureText(line);
67+
maxWidth = Math.max(maxWidth, lineWidth);
68+
}
69+
70+
return {
71+
width: maxWidth,
72+
height: lines.length * lineHeight,
73+
};
74+
}
75+
2976
export type RendererContext = {
3077
stage: Konva.Stage;
3178
layer: Konva.Layer;
@@ -214,21 +261,40 @@ function updateTextLayer(
214261
ctx: RendererContext,
215262
currentCue: currentPlayingCue | undefined,
216263
): void {
217-
ctx.text.setAttr("text", currentCue?.currentCue?.text ?? "");
264+
let text = currentCue?.currentCue?.text ?? "";
265+
266+
if (ctx.style.hidePunctuation && text) {
267+
text = stripPunctuation(text);
268+
}
269+
270+
ctx.text.setAttr("text", text);
218271

219272
if (ctx.background) {
220-
if (!currentCue) {
273+
if (!currentCue || !text.trim()) {
221274
ctx.background.hide();
222275
} else {
223276
ctx.background.show();
224-
}
277+
278+
// Calculate actual text dimensions for proper background sizing
279+
const measureText = createKonvaMeasureText(ctx.style);
280+
const actualDims = calculateActualTextDimensions(text, ctx.style, measureText);
281+
282+
// Calculate x position based on alignment
283+
let bgX = ctx.text.x() - ctx.style.background.paddingX;
284+
const alignLower = ctx.style.align.toLowerCase();
285+
if (alignLower === "center") {
286+
bgX = ctx.text.x() + (ctx.style.blockSize.width - actualDims.width) / 2 - ctx.style.background.paddingX;
287+
} else if (alignLower === "right") {
288+
bgX = ctx.text.x() + ctx.style.blockSize.width - actualDims.width - ctx.style.background.paddingX;
289+
}
225290

226-
ctx.background.setAttrs({
227-
x: ctx.text.x() - ctx.style.background.paddingX,
228-
y: ctx.text.y() - ctx.style.background.paddingY,
229-
width: ctx.text.width() + ctx.style.background.paddingX * 2,
230-
height: ctx.text.height() + ctx.style.background.paddingY * 2,
231-
});
291+
ctx.background.setAttrs({
292+
x: bgX,
293+
y: ctx.text.y() - ctx.style.background.paddingY,
294+
width: actualDims.width + ctx.style.background.paddingX * 2,
295+
height: actualDims.height + ctx.style.background.paddingY * 2,
296+
});
297+
}
232298
}
233299

234300
ctx.layer.draw();
@@ -351,12 +417,17 @@ function updateWordAnimationLayer(
351417
}
352418
}
353419

420+
let wordTextContent = pos.word.text.trim();
421+
if (ctx.style.hidePunctuation) {
422+
wordTextContent = stripPunctuation(wordTextContent);
423+
}
424+
354425
const wordText = new Text({
355426
x: pos.x - offsetX,
356427
y: pos.y - offsetY,
357428
scaleX: popScale,
358429
scaleY: popScale,
359-
text: pos.word.text.trim(),
430+
text: wordTextContent,
360431
fill: fillColor,
361432
fontSize: ctx.style.fontSizePx,
362433
fontStyle: fontWeight >= 700 ? "bold" : "normal",
@@ -372,11 +443,21 @@ function updateWordAnimationLayer(
372443
// redraw background for the whole block
373444
if (ctx.background) {
374445
const totalHeight = calculateTotalHeight(positions, ctx.style.fontSizePx);
446+
const actualWidth = calculateActualWidth(positions, ctx.style.fontSizePx);
447+
448+
// Calculate x position based on alignment
449+
let bgX = ctx.style.x - ctx.style.background.paddingX;
450+
const alignLower = ctx.style.align.toLowerCase();
451+
if (alignLower === "center") {
452+
bgX = ctx.style.x + (ctx.style.blockSize.width - actualWidth) / 2 - ctx.style.background.paddingX;
453+
} else if (alignLower === "right") {
454+
bgX = ctx.style.x + ctx.style.blockSize.width - actualWidth - ctx.style.background.paddingX;
455+
}
375456

376457
ctx.background.setAttrs({
377-
x: ctx.style.x - ctx.style.background.paddingX,
458+
x: bgX,
378459
y: ctx.style.y - ctx.style.background.paddingY,
379-
width: ctx.style.blockSize.width + ctx.style.background.paddingX * 2,
460+
width: actualWidth + ctx.style.background.paddingX * 2,
380461
height: totalHeight + ctx.style.background.paddingY * 2,
381462
});
382463
ctx.background.show();

src/hooks/useObservable.res

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,37 @@ module Pubsub = (Init: PubsubInit) => {
5858
let (_, forceUpdate) = React.useReducer((x, _) => x + 1, 0)
5959

6060
React.useEffect0(() => {
61-
let unsubscribe = subscribe(forceUpdate)
61+
let unsubscribe = subscribe(_ => forceUpdate())
6262

6363
Some(unsubscribe)
6464
})
6565

6666
get()
6767
}
68+
69+
// Selector-based hook that only re-renders when the selected value changes
70+
// This is critical for performance - components can subscribe to specific slices of state
71+
@genType
72+
let useObservableSelector = (selector: Init.t => 'a): 'a => {
73+
let selectedRef = React.useRef(selector(get()))
74+
let (_, forceUpdate) = React.useReducer((x, _) => x + 1, 0)
75+
76+
React.useEffect0(() => {
77+
let unsubscribe = subscribe(state => {
78+
let newSelected = selector(state)
79+
80+
// Only force update if the selected value actually changed
81+
if newSelected !== selectedRef.current {
82+
selectedRef.current = newSelected
83+
forceUpdate()
84+
}
85+
})
86+
87+
Some(unsubscribe)
88+
})
89+
90+
selectedRef.current
91+
}
6892
}
6993

7094
module MakeObserver: Observer = (Observable: Observable) => {

0 commit comments

Comments
 (0)