@remotion/web-renderer 4.0.429 → 4.0.430
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/dist/add-sample.js +20 -0
- package/dist/artifact.js +56 -0
- package/dist/audio.js +42 -0
- package/dist/can-use-webfs-target.js +19 -0
- package/dist/compose.js +85 -0
- package/dist/create-scaffold.js +104 -0
- package/dist/drawing/border-radius.js +151 -0
- package/dist/drawing/calculate-object-fit.js +208 -0
- package/dist/drawing/calculate-transforms.js +127 -0
- package/dist/drawing/clamp-rect-to-parent-bounds.js +18 -0
- package/dist/drawing/do-rects-intersect.js +6 -0
- package/dist/drawing/draw-background.js +62 -0
- package/dist/drawing/draw-border.js +353 -0
- package/dist/drawing/draw-box-shadow.js +103 -0
- package/dist/drawing/draw-dom-element.js +85 -0
- package/dist/drawing/draw-element.js +84 -0
- package/dist/drawing/draw-outline.js +93 -0
- package/dist/drawing/draw-rounded.js +34 -0
- package/dist/drawing/drawn-fn.js +1 -0
- package/dist/drawing/fit-svg-into-its-dimensions.js +35 -0
- package/dist/drawing/get-clipped-background.d.ts +8 -0
- package/dist/drawing/get-clipped-background.js +14 -0
- package/dist/drawing/get-padding-box.js +30 -0
- package/dist/drawing/get-pretransform-rect.js +49 -0
- package/dist/drawing/handle-3d-transform.js +26 -0
- package/dist/drawing/handle-mask.js +21 -0
- package/dist/drawing/has-transform.js +14 -0
- package/dist/drawing/mask-image.js +14 -0
- package/dist/drawing/opacity.js +7 -0
- package/dist/drawing/overflow.js +14 -0
- package/dist/drawing/parse-linear-gradient.js +260 -0
- package/dist/drawing/parse-transform-origin.js +7 -0
- package/dist/drawing/precompose.d.ts +11 -0
- package/dist/drawing/precompose.js +14 -0
- package/dist/drawing/process-node.js +122 -0
- package/dist/drawing/round-to-expand-rect.js +7 -0
- package/dist/drawing/text/apply-text-transform.js +12 -0
- package/dist/drawing/text/draw-text.js +53 -0
- package/dist/drawing/text/find-line-breaks.text.js +118 -0
- package/dist/drawing/text/get-collapsed-text.d.ts +1 -0
- package/dist/drawing/text/get-collapsed-text.js +46 -0
- package/dist/drawing/text/handle-text-node.js +24 -0
- package/dist/drawing/text/parse-paint-order.d.ts +8 -0
- package/dist/drawing/transform-in-3d.js +177 -0
- package/dist/drawing/transform-rect-with-matrix.js +19 -0
- package/dist/drawing/transform.js +10 -0
- package/dist/drawing/turn-svg-into-drawable.js +41 -0
- package/dist/esm/index.mjs +34 -2
- package/dist/frame-range.js +15 -0
- package/dist/get-audio-encoding-config.js +18 -0
- package/dist/get-biggest-bounding-client-rect.js +43 -0
- package/dist/index.js +2 -0
- package/dist/internal-state.js +36 -0
- package/dist/mediabunny-mappings.js +63 -0
- package/dist/output-target.js +1 -0
- package/dist/props-if-has-props.js +1 -0
- package/dist/render-media-on-web.js +304 -0
- package/dist/render-operations-queue.js +3 -0
- package/dist/render-still-on-web.js +110 -0
- package/dist/send-telemetry-event.js +22 -0
- package/dist/take-screenshot.js +30 -0
- package/dist/throttle-progress.js +43 -0
- package/dist/tree-walker-cleanup-after-children.js +33 -0
- package/dist/update-time.js +17 -0
- package/dist/validate-video-frame.js +34 -0
- package/dist/wait-for-ready.js +39 -0
- package/dist/walk-tree.js +14 -0
- package/dist/web-fs-target.js +41 -0
- package/dist/with-resolvers.js +9 -0
- package/package.json +3 -2
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { NoReactInternals } from 'remotion/no-react';
|
|
2
|
+
const isValidColor = (color) => {
|
|
3
|
+
try {
|
|
4
|
+
const result = NoReactInternals.processColor(color);
|
|
5
|
+
return result !== null && result !== undefined;
|
|
6
|
+
}
|
|
7
|
+
catch (_a) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
const parseDirection = (directionStr) => {
|
|
12
|
+
const trimmed = directionStr.trim().toLowerCase();
|
|
13
|
+
// Handle keywords like "to right", "to bottom", etc.
|
|
14
|
+
if (trimmed.startsWith('to ')) {
|
|
15
|
+
const direction = trimmed.substring(3).trim();
|
|
16
|
+
switch (direction) {
|
|
17
|
+
case 'top':
|
|
18
|
+
return 0;
|
|
19
|
+
case 'right':
|
|
20
|
+
return 90;
|
|
21
|
+
case 'bottom':
|
|
22
|
+
return 180;
|
|
23
|
+
case 'left':
|
|
24
|
+
return 270;
|
|
25
|
+
case 'top right':
|
|
26
|
+
case 'right top':
|
|
27
|
+
return 45;
|
|
28
|
+
case 'bottom right':
|
|
29
|
+
case 'right bottom':
|
|
30
|
+
return 135;
|
|
31
|
+
case 'bottom left':
|
|
32
|
+
case 'left bottom':
|
|
33
|
+
return 225;
|
|
34
|
+
case 'top left':
|
|
35
|
+
case 'left top':
|
|
36
|
+
return 315;
|
|
37
|
+
default:
|
|
38
|
+
return 180; // Default to bottom
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Handle angle values: deg, rad, grad, turn
|
|
42
|
+
const angleMatch = trimmed.match(/^(-?\d+\.?\d*)(deg|rad|grad|turn)$/);
|
|
43
|
+
if (angleMatch) {
|
|
44
|
+
const value = parseFloat(angleMatch[1]);
|
|
45
|
+
const unit = angleMatch[2];
|
|
46
|
+
switch (unit) {
|
|
47
|
+
case 'deg':
|
|
48
|
+
return value;
|
|
49
|
+
case 'rad':
|
|
50
|
+
return (value * 180) / Math.PI;
|
|
51
|
+
case 'grad':
|
|
52
|
+
return (value * 360) / 400;
|
|
53
|
+
case 'turn':
|
|
54
|
+
return value * 360;
|
|
55
|
+
default:
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Default: to bottom
|
|
60
|
+
return 180;
|
|
61
|
+
};
|
|
62
|
+
const parseColorStops = (colorStopsStr) => {
|
|
63
|
+
// Split by comma, but respect parentheses in rgba(), rgb(), hsl(), hsla()
|
|
64
|
+
const parts = colorStopsStr.split(/,(?![^(]*\))/);
|
|
65
|
+
const stops = [];
|
|
66
|
+
for (const part of parts) {
|
|
67
|
+
const trimmed = part.trim();
|
|
68
|
+
if (!trimmed)
|
|
69
|
+
continue;
|
|
70
|
+
// Extract color: can be rgb(), rgba(), hsl(), hsla(), hex, or named color
|
|
71
|
+
const colorMatch = trimmed.match(/(rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-f]{3,8}|[a-z]+)/i);
|
|
72
|
+
if (!colorMatch) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const colorStr = colorMatch[0];
|
|
76
|
+
// Validate that this is actually a valid CSS color
|
|
77
|
+
if (!isValidColor(colorStr)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const remaining = trimmed
|
|
81
|
+
.substring(colorMatch.index + colorStr.length)
|
|
82
|
+
.trim();
|
|
83
|
+
// Canvas API supports CSS colors directly, so we can use the color string as-is
|
|
84
|
+
const normalizedColor = colorStr;
|
|
85
|
+
// Parse position if provided
|
|
86
|
+
let position = null;
|
|
87
|
+
if (remaining) {
|
|
88
|
+
const posMatch = remaining.match(/(-?\d+\.?\d*)(%|px)?/);
|
|
89
|
+
if (posMatch) {
|
|
90
|
+
const value = parseFloat(posMatch[1]);
|
|
91
|
+
const unit = posMatch[2];
|
|
92
|
+
if (unit === '%') {
|
|
93
|
+
position = value / 100;
|
|
94
|
+
}
|
|
95
|
+
else if (unit === 'px') {
|
|
96
|
+
// px values need element dimensions, which we don't have here
|
|
97
|
+
// We'll handle this as a percentage for now (not fully CSS-compliant but good enough)
|
|
98
|
+
position = null;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
position = value / 100; // Assume percentage if no unit
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
stops.push({
|
|
106
|
+
color: normalizedColor,
|
|
107
|
+
position: position !== null ? position : -1, // -1 means needs to be calculated
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (stops.length === 0) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
// Distribute positions evenly for stops that don't have explicit positions
|
|
114
|
+
let lastExplicitIndex = -1;
|
|
115
|
+
let lastExplicitPosition = 0;
|
|
116
|
+
for (let i = 0; i < stops.length; i++) {
|
|
117
|
+
if (stops[i].position !== -1) {
|
|
118
|
+
// Found an explicit position
|
|
119
|
+
if (lastExplicitIndex >= 0) {
|
|
120
|
+
// Interpolate between last explicit and current explicit
|
|
121
|
+
const numImplicit = i - lastExplicitIndex - 1;
|
|
122
|
+
if (numImplicit > 0) {
|
|
123
|
+
const step = (stops[i].position - lastExplicitPosition) / (numImplicit + 1);
|
|
124
|
+
for (let j = lastExplicitIndex + 1; j < i; j++) {
|
|
125
|
+
stops[j].position =
|
|
126
|
+
lastExplicitPosition + step * (j - lastExplicitIndex);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
// Backfill from start to first explicit
|
|
132
|
+
const numImplicit = i;
|
|
133
|
+
if (numImplicit > 0) {
|
|
134
|
+
const step = stops[i].position / (numImplicit + 1);
|
|
135
|
+
for (let j = 0; j < i; j++) {
|
|
136
|
+
stops[j].position = step * (j + 1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
lastExplicitIndex = i;
|
|
141
|
+
lastExplicitPosition = stops[i].position;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// If no explicit positions were provided at all, distribute evenly
|
|
145
|
+
// Check this BEFORE handling trailing stops
|
|
146
|
+
if (stops.every((s) => s.position === -1)) {
|
|
147
|
+
if (stops.length === 1) {
|
|
148
|
+
stops[0].position = 0.5;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
for (let i = 0; i < stops.length; i++) {
|
|
152
|
+
stops[i].position = i / (stops.length - 1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else if (lastExplicitIndex < stops.length - 1) {
|
|
157
|
+
const numImplicit = stops.length - 1 - lastExplicitIndex;
|
|
158
|
+
const step = (1 - lastExplicitPosition) / (numImplicit + 1);
|
|
159
|
+
for (let i = lastExplicitIndex + 1; i < stops.length; i++) {
|
|
160
|
+
stops[i].position = lastExplicitPosition + step * (i - lastExplicitIndex);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Clamp positions to 0-1
|
|
164
|
+
for (const stop of stops) {
|
|
165
|
+
stop.position = Math.max(0, Math.min(1, stop.position));
|
|
166
|
+
}
|
|
167
|
+
return stops;
|
|
168
|
+
};
|
|
169
|
+
const extractGradientContent = (backgroundImage) => {
|
|
170
|
+
const prefix = 'linear-gradient(';
|
|
171
|
+
const startIndex = backgroundImage.toLowerCase().indexOf(prefix);
|
|
172
|
+
if (startIndex === -1) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
// Find matching closing parenthesis, handling nested parens from rgb(), rgba(), etc.
|
|
176
|
+
let depth = 0;
|
|
177
|
+
const contentStart = startIndex + prefix.length;
|
|
178
|
+
for (let i = contentStart; i < backgroundImage.length; i++) {
|
|
179
|
+
const char = backgroundImage[i];
|
|
180
|
+
if (char === '(') {
|
|
181
|
+
depth++;
|
|
182
|
+
}
|
|
183
|
+
else if (char === ')') {
|
|
184
|
+
if (depth === 0) {
|
|
185
|
+
return backgroundImage.substring(contentStart, i).trim();
|
|
186
|
+
}
|
|
187
|
+
depth--;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
};
|
|
192
|
+
export const parseLinearGradient = (backgroundImage) => {
|
|
193
|
+
if (!backgroundImage || backgroundImage === 'none') {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const content = extractGradientContent(backgroundImage);
|
|
197
|
+
if (!content) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
// Try to identify the direction/angle part vs color stops
|
|
201
|
+
// Direction/angle is optional and comes first
|
|
202
|
+
// It can be: "to right", "45deg", etc.
|
|
203
|
+
// Split into parts, respecting parentheses
|
|
204
|
+
const parts = content.split(/,(?![^(]*\))/);
|
|
205
|
+
let angle = 180; // Default: to bottom
|
|
206
|
+
let colorStopsStart = 0;
|
|
207
|
+
// Check if first part is a direction/angle
|
|
208
|
+
if (parts.length > 0) {
|
|
209
|
+
const firstPart = parts[0].trim();
|
|
210
|
+
// Check if it looks like a direction or angle (not a color)
|
|
211
|
+
const isDirection = firstPart.startsWith('to ') ||
|
|
212
|
+
/^-?\d+\.?\d*(deg|rad|grad|turn)$/.test(firstPart);
|
|
213
|
+
if (isDirection) {
|
|
214
|
+
angle = parseDirection(firstPart);
|
|
215
|
+
colorStopsStart = 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Parse color stops
|
|
219
|
+
const colorStopsStr = parts.slice(colorStopsStart).join(',');
|
|
220
|
+
const colorStops = parseColorStops(colorStopsStr);
|
|
221
|
+
if (!colorStops || colorStops.length === 0) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
angle,
|
|
226
|
+
colorStops,
|
|
227
|
+
};
|
|
228
|
+
};
|
|
229
|
+
export const createCanvasGradient = ({ ctx, rect, gradientInfo, offsetLeft, offsetTop, }) => {
|
|
230
|
+
// Convert angle to radians
|
|
231
|
+
// CSS angles: 0deg = to top, 90deg = to right, 180deg = to bottom, 270deg = to left
|
|
232
|
+
// We need to calculate the gradient line that spans the rectangle at the given angle
|
|
233
|
+
const angleRad = ((gradientInfo.angle - 90) * Math.PI) / 180;
|
|
234
|
+
const centerX = rect.left - offsetLeft + rect.width / 2;
|
|
235
|
+
const centerY = rect.top - offsetTop + rect.height / 2;
|
|
236
|
+
// Calculate gradient line endpoints
|
|
237
|
+
// The gradient line passes through the center and has the specified angle
|
|
238
|
+
const cos = Math.cos(angleRad);
|
|
239
|
+
const sin = Math.sin(angleRad);
|
|
240
|
+
// Find the intersection of the gradient line with the rectangle edges
|
|
241
|
+
const halfWidth = rect.width / 2;
|
|
242
|
+
const halfHeight = rect.height / 2;
|
|
243
|
+
// Calculate the length from center to edge along the gradient line.
|
|
244
|
+
// Primary formula should always be > 0 for valid angles and non-zero rects;
|
|
245
|
+
// fall back to diagonal length only for degenerate or invalid cases.
|
|
246
|
+
let length = Math.abs(cos) * halfWidth + Math.abs(sin) * halfHeight;
|
|
247
|
+
if (!Number.isFinite(length) || length === 0) {
|
|
248
|
+
length = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
|
|
249
|
+
}
|
|
250
|
+
const x0 = centerX - cos * length;
|
|
251
|
+
const y0 = centerY - sin * length;
|
|
252
|
+
const x1 = centerX + cos * length;
|
|
253
|
+
const y1 = centerY + sin * length;
|
|
254
|
+
const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
|
|
255
|
+
// Add color stops
|
|
256
|
+
for (const stop of gradientInfo.colorStops) {
|
|
257
|
+
gradient.addColorStop(stop.position, stop.color);
|
|
258
|
+
}
|
|
259
|
+
return gradient;
|
|
260
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { LogLevel } from 'remotion';
|
|
2
|
+
import type { InternalState } from '../internal-state';
|
|
3
|
+
export declare const precomposeDOMElement: ({ boundingRect, element, logLevel, internalState, }: {
|
|
4
|
+
boundingRect: DOMRect;
|
|
5
|
+
element: HTMLElement | SVGElement;
|
|
6
|
+
logLevel: LogLevel;
|
|
7
|
+
internalState: InternalState;
|
|
8
|
+
}) => Promise<{
|
|
9
|
+
tempCanvas: OffscreenCanvas;
|
|
10
|
+
tempContext: OffscreenCanvasRenderingContext2D;
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { compose } from '../compose';
|
|
2
|
+
export const precomposeDOMElement = async ({ boundingRect, element, logLevel, internalState, }) => {
|
|
3
|
+
const tempCanvas = new OffscreenCanvas(boundingRect.width, boundingRect.height);
|
|
4
|
+
const tempContext = tempCanvas.getContext('2d');
|
|
5
|
+
await compose({
|
|
6
|
+
element,
|
|
7
|
+
context: tempContext,
|
|
8
|
+
logLevel,
|
|
9
|
+
parentRect: boundingRect,
|
|
10
|
+
internalState,
|
|
11
|
+
onlyBackgroundClip: false,
|
|
12
|
+
});
|
|
13
|
+
return { tempCanvas, tempContext };
|
|
14
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Internals } from 'remotion';
|
|
2
|
+
import { calculateTransforms } from './calculate-transforms';
|
|
3
|
+
import { getWiderRectAndExpand } from './clamp-rect-to-parent-bounds';
|
|
4
|
+
import { doRectsIntersect } from './do-rects-intersect';
|
|
5
|
+
import { drawElement } from './draw-element';
|
|
6
|
+
import { getPrecomposeRectFor3DTransform, handle3dTransform, } from './handle-3d-transform';
|
|
7
|
+
import { getPrecomposeRectForMask, handleMask } from './handle-mask';
|
|
8
|
+
import { precomposeDOMElement } from './precompose';
|
|
9
|
+
import { roundToExpandRect } from './round-to-expand-rect';
|
|
10
|
+
import { transformDOMRect } from './transform-rect-with-matrix';
|
|
11
|
+
export const processNode = async ({ element, context, draw, logLevel, parentRect, internalState, rootElement, }) => {
|
|
12
|
+
const { totalMatrix, reset, dimensions, opacity, computedStyle, precompositing, } = calculateTransforms({
|
|
13
|
+
element,
|
|
14
|
+
rootElement,
|
|
15
|
+
});
|
|
16
|
+
if (opacity === 0) {
|
|
17
|
+
reset();
|
|
18
|
+
return { type: 'skip-children' };
|
|
19
|
+
}
|
|
20
|
+
// When backfaceVisibility is 'hidden', don't render if the element is rotated
|
|
21
|
+
// to show its backface. The backface is visible when the z-component of the
|
|
22
|
+
// transformed normal vector (0, 0, 1) is negative, which corresponds to m33 < 0.
|
|
23
|
+
if (computedStyle.backfaceVisibility === 'hidden' && totalMatrix.m33 < 0) {
|
|
24
|
+
reset();
|
|
25
|
+
return { type: 'skip-children' };
|
|
26
|
+
}
|
|
27
|
+
if (dimensions.width <= 0 || dimensions.height <= 0) {
|
|
28
|
+
reset();
|
|
29
|
+
return { type: 'continue', cleanupAfterChildren: null };
|
|
30
|
+
}
|
|
31
|
+
const rect = new DOMRect(dimensions.left - parentRect.x, dimensions.top - parentRect.y, dimensions.width, dimensions.height);
|
|
32
|
+
if (precompositing.needsPrecompositing) {
|
|
33
|
+
const start = Date.now();
|
|
34
|
+
let precomposeRect = null;
|
|
35
|
+
if (precompositing.needsMaskImage) {
|
|
36
|
+
precomposeRect = getWiderRectAndExpand({
|
|
37
|
+
firstRect: precomposeRect,
|
|
38
|
+
secondRect: getPrecomposeRectForMask(element),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (precompositing.needs3DTransformViaWebGL) {
|
|
42
|
+
precomposeRect = getWiderRectAndExpand({
|
|
43
|
+
firstRect: precomposeRect,
|
|
44
|
+
secondRect: getPrecomposeRectFor3DTransform({
|
|
45
|
+
element,
|
|
46
|
+
parentRect,
|
|
47
|
+
matrix: totalMatrix,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (!precomposeRect) {
|
|
52
|
+
throw new Error('Precompose rect not found');
|
|
53
|
+
}
|
|
54
|
+
if (precomposeRect.width <= 0 || precomposeRect.height <= 0) {
|
|
55
|
+
return { type: 'continue', cleanupAfterChildren: null };
|
|
56
|
+
}
|
|
57
|
+
if (!doRectsIntersect(precomposeRect, parentRect)) {
|
|
58
|
+
return { type: 'continue', cleanupAfterChildren: null };
|
|
59
|
+
}
|
|
60
|
+
const { tempCanvas, tempContext } = await precomposeDOMElement({
|
|
61
|
+
boundingRect: precomposeRect,
|
|
62
|
+
element,
|
|
63
|
+
logLevel,
|
|
64
|
+
internalState,
|
|
65
|
+
});
|
|
66
|
+
let drawable = tempCanvas;
|
|
67
|
+
const rectAfterTransforms = roundToExpandRect(transformDOMRect({
|
|
68
|
+
rect: precomposeRect,
|
|
69
|
+
matrix: totalMatrix,
|
|
70
|
+
}));
|
|
71
|
+
if (precompositing.needsMaskImage) {
|
|
72
|
+
handleMask({
|
|
73
|
+
gradientInfo: precompositing.needsMaskImage,
|
|
74
|
+
rect,
|
|
75
|
+
precomposeRect,
|
|
76
|
+
tempContext,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (precompositing.needs3DTransformViaWebGL) {
|
|
80
|
+
const t = handle3dTransform({
|
|
81
|
+
matrix: totalMatrix,
|
|
82
|
+
precomposeRect,
|
|
83
|
+
tempCanvas: drawable,
|
|
84
|
+
rectAfterTransforms,
|
|
85
|
+
internalState,
|
|
86
|
+
});
|
|
87
|
+
if (t) {
|
|
88
|
+
drawable = t;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const previousTransform = context.getTransform();
|
|
92
|
+
if (drawable) {
|
|
93
|
+
context.setTransform(new DOMMatrix());
|
|
94
|
+
context.drawImage(drawable, 0, drawable.height - rectAfterTransforms.height, rectAfterTransforms.width, rectAfterTransforms.height, rectAfterTransforms.left - parentRect.x, rectAfterTransforms.top - parentRect.y, rectAfterTransforms.width, rectAfterTransforms.height);
|
|
95
|
+
context.setTransform(previousTransform);
|
|
96
|
+
Internals.Log.trace({
|
|
97
|
+
logLevel,
|
|
98
|
+
tag: '@remotion/web-renderer',
|
|
99
|
+
}, `Transforming element in 3D - canvas size: ${precomposeRect.width}x${precomposeRect.height} - compose: ${Date.now() - start}ms`);
|
|
100
|
+
internalState.addPrecompose({
|
|
101
|
+
canvasWidth: precomposeRect.width,
|
|
102
|
+
canvasHeight: precomposeRect.height,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
reset();
|
|
106
|
+
return { type: 'skip-children' };
|
|
107
|
+
}
|
|
108
|
+
const { cleanupAfterChildren } = await drawElement({
|
|
109
|
+
rect,
|
|
110
|
+
computedStyle,
|
|
111
|
+
context,
|
|
112
|
+
draw,
|
|
113
|
+
opacity,
|
|
114
|
+
totalMatrix,
|
|
115
|
+
parentRect,
|
|
116
|
+
logLevel,
|
|
117
|
+
element,
|
|
118
|
+
internalState,
|
|
119
|
+
});
|
|
120
|
+
reset();
|
|
121
|
+
return { type: 'continue', cleanupAfterChildren };
|
|
122
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const applyTextTransform = (text, transform) => {
|
|
2
|
+
if (transform === 'uppercase') {
|
|
3
|
+
return text.toUpperCase();
|
|
4
|
+
}
|
|
5
|
+
if (transform === 'lowercase') {
|
|
6
|
+
return text.toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
if (transform === 'capitalize') {
|
|
9
|
+
return text.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
10
|
+
}
|
|
11
|
+
return text;
|
|
12
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Internals } from 'remotion';
|
|
2
|
+
import { applyTextTransform } from './apply-text-transform';
|
|
3
|
+
import { findLineBreaks } from './find-line-breaks.text';
|
|
4
|
+
import { getCollapsedText } from './get-collapsed-text';
|
|
5
|
+
export const drawText = ({ span, logLevel, onlyBackgroundClip, }) => {
|
|
6
|
+
const drawFn = ({ dimensions: rect, computedStyle, contextToDraw }) => {
|
|
7
|
+
const { fontFamily, fontSize, fontWeight, direction, writingMode, letterSpacing, textTransform, webkitTextFillColor, } = computedStyle;
|
|
8
|
+
const isVertical = writingMode !== 'horizontal-tb';
|
|
9
|
+
if (isVertical) {
|
|
10
|
+
// TODO: Only warn once per render.
|
|
11
|
+
Internals.Log.warn({
|
|
12
|
+
logLevel,
|
|
13
|
+
tag: '@remotion/web-renderer',
|
|
14
|
+
}, 'Detected "writing-mode" CSS property. Vertical text is not yet supported in @remotion/web-renderer');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
contextToDraw.save();
|
|
18
|
+
const fontSizePx = parseFloat(fontSize);
|
|
19
|
+
contextToDraw.font = `${fontWeight} ${fontSizePx}px ${fontFamily}`;
|
|
20
|
+
contextToDraw.fillStyle =
|
|
21
|
+
// If text is being applied with backgroundClipText, we need to use a solid color otherwise it won't get
|
|
22
|
+
// applied in canvas
|
|
23
|
+
onlyBackgroundClip
|
|
24
|
+
? 'black'
|
|
25
|
+
: // -webkit-text-fill-color overrides color, and defaults to the value of `color`
|
|
26
|
+
webkitTextFillColor;
|
|
27
|
+
contextToDraw.letterSpacing = letterSpacing;
|
|
28
|
+
const isRTL = direction === 'rtl';
|
|
29
|
+
contextToDraw.textAlign = isRTL ? 'right' : 'left';
|
|
30
|
+
contextToDraw.textBaseline = 'alphabetic';
|
|
31
|
+
const originalText = span.textContent;
|
|
32
|
+
const collapsedText = getCollapsedText(span);
|
|
33
|
+
const transformedText = applyTextTransform(collapsedText, textTransform);
|
|
34
|
+
span.textContent = transformedText;
|
|
35
|
+
// For RTL text, fill from the right edge instead of left
|
|
36
|
+
const xPosition = isRTL ? rect.right : rect.left;
|
|
37
|
+
const lines = findLineBreaks(span, isRTL);
|
|
38
|
+
let offsetTop = 0;
|
|
39
|
+
const measurements = contextToDraw.measureText(lines[0].text);
|
|
40
|
+
const { fontBoundingBoxDescent, fontBoundingBoxAscent } = measurements;
|
|
41
|
+
const fontHeight = fontBoundingBoxAscent + fontBoundingBoxDescent;
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
// Calculate leading
|
|
44
|
+
const leading = line.height - fontHeight;
|
|
45
|
+
const halfLeading = leading / 2;
|
|
46
|
+
contextToDraw.fillText(line.text, xPosition + line.offsetHorizontal, rect.top + halfLeading + fontBoundingBoxAscent + offsetTop);
|
|
47
|
+
offsetTop += line.height;
|
|
48
|
+
}
|
|
49
|
+
span.textContent = originalText;
|
|
50
|
+
contextToDraw.restore();
|
|
51
|
+
};
|
|
52
|
+
return drawFn;
|
|
53
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { getCollapsedText } from './get-collapsed-text';
|
|
2
|
+
// Punctuation that cannot start a line according to Unicode line breaking rules
|
|
3
|
+
// When these would start a line, the browser moves the preceding word to the new line
|
|
4
|
+
const cannotStartLine = (segment) => {
|
|
5
|
+
if (segment.length === 0)
|
|
6
|
+
return false;
|
|
7
|
+
const firstChar = segment[0];
|
|
8
|
+
const forbiddenLineStarts = [
|
|
9
|
+
'.',
|
|
10
|
+
',',
|
|
11
|
+
';',
|
|
12
|
+
':',
|
|
13
|
+
'!',
|
|
14
|
+
'?',
|
|
15
|
+
')',
|
|
16
|
+
']',
|
|
17
|
+
'}',
|
|
18
|
+
'"',
|
|
19
|
+
"'",
|
|
20
|
+
'"',
|
|
21
|
+
`'`,
|
|
22
|
+
'»',
|
|
23
|
+
'…',
|
|
24
|
+
'‥',
|
|
25
|
+
'·',
|
|
26
|
+
'%',
|
|
27
|
+
'‰',
|
|
28
|
+
];
|
|
29
|
+
return forbiddenLineStarts.includes(firstChar);
|
|
30
|
+
};
|
|
31
|
+
export function findLineBreaks(span, rtl) {
|
|
32
|
+
const textNode = span.childNodes[0];
|
|
33
|
+
const originalText = textNode.textContent;
|
|
34
|
+
const originalRect = span.getBoundingClientRect();
|
|
35
|
+
const computedStyle = getComputedStyle(span);
|
|
36
|
+
const segmenter = new Intl.Segmenter('en', { granularity: 'word' });
|
|
37
|
+
const segments = segmenter.segment(originalText);
|
|
38
|
+
const words = Array.from(segments).map((s) => s.segment);
|
|
39
|
+
// If the text would be centered in a flexbox container
|
|
40
|
+
const lines = [];
|
|
41
|
+
let currentLine = '';
|
|
42
|
+
let testText = '';
|
|
43
|
+
let previousRect = originalRect;
|
|
44
|
+
textNode.textContent = '';
|
|
45
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
46
|
+
const word = words[i];
|
|
47
|
+
testText += word;
|
|
48
|
+
let wordsToAdd = word;
|
|
49
|
+
while (typeof words[i + 1] !== 'undefined' && words[i + 1].trim() === '') {
|
|
50
|
+
testText += words[i + 1];
|
|
51
|
+
wordsToAdd += words[i + 1];
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
previousRect = span.getBoundingClientRect();
|
|
55
|
+
textNode.textContent = testText;
|
|
56
|
+
const collapsedText = getCollapsedText(span);
|
|
57
|
+
textNode.textContent = collapsedText;
|
|
58
|
+
const rect = span.getBoundingClientRect();
|
|
59
|
+
const currentHeight = rect.height;
|
|
60
|
+
// If height changed significantly, we had a line break
|
|
61
|
+
if (previousRect &&
|
|
62
|
+
previousRect.height !== 0 &&
|
|
63
|
+
Math.abs(currentHeight - previousRect.height) > 2) {
|
|
64
|
+
const offsetHorizontal = rtl
|
|
65
|
+
? previousRect.right - originalRect.right
|
|
66
|
+
: previousRect.left - originalRect.left;
|
|
67
|
+
const shouldCollapse = !computedStyle.whiteSpaceCollapse.includes('preserve');
|
|
68
|
+
let textForPreviousLine = currentLine;
|
|
69
|
+
let textForNewLine = wordsToAdd;
|
|
70
|
+
// If the segment that triggered the break can't start a line (e.g., punctuation),
|
|
71
|
+
// the browser would have moved the preceding word to the new line as well
|
|
72
|
+
if (cannotStartLine(word)) {
|
|
73
|
+
const currentLineSegments = Array.from(segmenter.segment(currentLine)).map((s) => s.segment);
|
|
74
|
+
// Find the last non-whitespace segment (the word to move)
|
|
75
|
+
let lastWordIndex = currentLineSegments.length - 1;
|
|
76
|
+
while (lastWordIndex >= 0 &&
|
|
77
|
+
currentLineSegments[lastWordIndex].trim() === '') {
|
|
78
|
+
lastWordIndex--;
|
|
79
|
+
}
|
|
80
|
+
if (lastWordIndex >= 0) {
|
|
81
|
+
// Move the last word (and any trailing whitespace) to the new line
|
|
82
|
+
textForPreviousLine = currentLineSegments
|
|
83
|
+
.slice(0, lastWordIndex)
|
|
84
|
+
.join('');
|
|
85
|
+
textForNewLine =
|
|
86
|
+
currentLineSegments.slice(lastWordIndex).join('') + wordsToAdd;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
lines.push({
|
|
90
|
+
text: shouldCollapse ? textForPreviousLine.trim() : textForPreviousLine,
|
|
91
|
+
height: currentHeight - previousRect.height,
|
|
92
|
+
offsetHorizontal,
|
|
93
|
+
});
|
|
94
|
+
currentLine = textForNewLine;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
currentLine += wordsToAdd;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Add the last line
|
|
101
|
+
if (currentLine) {
|
|
102
|
+
textNode.textContent = testText;
|
|
103
|
+
const rects = span.getClientRects();
|
|
104
|
+
const rect = span.getBoundingClientRect();
|
|
105
|
+
const lastRect = rects[rects.length - 1];
|
|
106
|
+
const offsetHorizontal = rtl
|
|
107
|
+
? lastRect.right - originalRect.right
|
|
108
|
+
: lastRect.left - originalRect.left;
|
|
109
|
+
lines.push({
|
|
110
|
+
text: currentLine,
|
|
111
|
+
height: rect.height - lines.reduce((acc, curr) => acc + curr.height, 0),
|
|
112
|
+
offsetHorizontal,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// Reset to original text
|
|
116
|
+
textNode.textContent = originalText;
|
|
117
|
+
return lines;
|
|
118
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const getCollapsedText: (span: HTMLSpanElement) => string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const getCollapsedText = (span) => {
|
|
2
|
+
const textNode = span.firstChild;
|
|
3
|
+
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
|
|
4
|
+
throw new Error('Span must contain a single text node');
|
|
5
|
+
}
|
|
6
|
+
const originalText = textNode.textContent || '';
|
|
7
|
+
let collapsedText = originalText;
|
|
8
|
+
// Helper to measure width
|
|
9
|
+
const measureWidth = (text) => {
|
|
10
|
+
textNode.textContent = text;
|
|
11
|
+
return span.getBoundingClientRect().width;
|
|
12
|
+
};
|
|
13
|
+
const originalWidth = measureWidth(originalText);
|
|
14
|
+
// Test leading whitespace
|
|
15
|
+
if (/^\s/.test(collapsedText)) {
|
|
16
|
+
const trimmedLeading = collapsedText.replace(/^\s+/, '');
|
|
17
|
+
const newWidth = measureWidth(trimmedLeading);
|
|
18
|
+
if (newWidth === originalWidth) {
|
|
19
|
+
// Whitespace was collapsed by the browser
|
|
20
|
+
collapsedText = trimmedLeading;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Test trailing whitespace (on current collapsed text)
|
|
24
|
+
if (/\s$/.test(collapsedText)) {
|
|
25
|
+
const currentWidth = measureWidth(collapsedText);
|
|
26
|
+
const trimmedTrailing = collapsedText.replace(/\s+$/, '');
|
|
27
|
+
const newWidth = measureWidth(trimmedTrailing);
|
|
28
|
+
if (newWidth === currentWidth) {
|
|
29
|
+
// Whitespace was collapsed by the browser
|
|
30
|
+
collapsedText = trimmedTrailing;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Test internal duplicate whitespace (on current collapsed text)
|
|
34
|
+
if (/\s\s/.test(collapsedText)) {
|
|
35
|
+
const currentWidth = measureWidth(collapsedText);
|
|
36
|
+
const collapsedInternal = collapsedText.replace(/\s\s+/g, ' ');
|
|
37
|
+
const newWidth = measureWidth(collapsedInternal);
|
|
38
|
+
if (newWidth === currentWidth) {
|
|
39
|
+
// Whitespace was collapsed by the browser
|
|
40
|
+
collapsedText = collapsedInternal;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Restore original text
|
|
44
|
+
textNode.textContent = originalText;
|
|
45
|
+
return collapsedText;
|
|
46
|
+
};
|