@hypit/caption-fine 0.0.0-stage → 0.1.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/LICENSE +26 -0
- package/README.md +154 -2
- package/dist/activation.d.ts +8 -0
- package/dist/activation.js +30 -0
- package/dist/component.d.ts +37 -0
- package/dist/component.js +34 -0
- package/dist/fragment.d.ts +4 -0
- package/dist/fragment.js +84 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/joined-box.d.ts +6 -0
- package/dist/joined-box.js +114 -0
- package/dist/manifest.d.ts +854 -0
- package/dist/manifest.js +387 -0
- package/dist/recipe.d.ts +18 -0
- package/dist/recipe.js +116 -0
- package/dist/render.d.ts +8 -0
- package/dist/render.js +1043 -0
- package/dist/schedule.d.ts +12 -0
- package/dist/schedule.js +135 -0
- package/dist/spacing.d.ts +7 -0
- package/dist/spacing.js +13 -0
- package/dist/studio.d.ts +7 -0
- package/dist/studio.js +175 -0
- package/dist/style.d.ts +8 -0
- package/dist/style.js +340 -0
- package/dist/surface.d.ts +3 -0
- package/dist/surface.js +171 -0
- package/dist/types.d.ts +163 -0
- package/dist/types.js +1 -0
- package/package.json +26 -4
- package/preview/Track.png +0 -0
package/dist/render.js
ADDED
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
import { assertTimelineIdentity, timelineFrameCount } from "@hypit/hypit/timeline";
|
|
2
|
+
import { assertCaptionProgramForDocument } from "@hypit/hypit/caption";
|
|
3
|
+
import { assertVisualTrackIdentity, sealVisualTrack } from "@hypit/hypit/composition";
|
|
4
|
+
import { assertRegionEvidence } from "@hypit/hypit/region-evidence";
|
|
5
|
+
import { assertSpatialFrame } from "@hypit/hypit/spatial";
|
|
6
|
+
import { assertFineCaptionParameters, FINE_CAPTION_FAMILY } from "./style.js";
|
|
7
|
+
import { assertFineCaptionSchedule } from "./schedule.js";
|
|
8
|
+
import { uniformGap, wordGaps } from "./spacing.js";
|
|
9
|
+
import { htmlVisual } from "@hypit/hypit/html-program";
|
|
10
|
+
import { joinedBoxSetup } from "./joined-box.js";
|
|
11
|
+
function clamp(value, minimum, maximum) {
|
|
12
|
+
return Math.max(minimum, Math.min(maximum, value));
|
|
13
|
+
}
|
|
14
|
+
function compactNumber(value) {
|
|
15
|
+
return String(Math.round(value * 1_000_000) / 1_000_000);
|
|
16
|
+
}
|
|
17
|
+
function alphaColor(hex, opacity) {
|
|
18
|
+
const red = Number.parseInt(hex.slice(1, 3), 16);
|
|
19
|
+
const green = Number.parseInt(hex.slice(3, 5), 16);
|
|
20
|
+
const blue = Number.parseInt(hex.slice(5, 7), 16);
|
|
21
|
+
const authoredAlpha = hex.length === 9 ? Number.parseInt(hex.slice(7, 9), 16) / 255 : 1;
|
|
22
|
+
return `rgba(${red},${green},${blue},${compactNumber(authoredAlpha * opacity)})`;
|
|
23
|
+
}
|
|
24
|
+
function typographyStyle(parameters) {
|
|
25
|
+
return [
|
|
26
|
+
{ name: "font-size", value: `${compactNumber(parameters.typography.fontSizePx)}px` },
|
|
27
|
+
{ name: "font-kerning", value: parameters.typography.kerning },
|
|
28
|
+
{ name: "font-variant-caps", value: parameters.typography.variantCaps },
|
|
29
|
+
{ name: "letter-spacing", value: `${compactNumber(parameters.layout.letterSpacingPx)}px` },
|
|
30
|
+
{ name: "line-height", value: parameters.layout.lineHeight },
|
|
31
|
+
{ name: "text-transform", value: parameters.typography.textTransform },
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
function underlineStyle(underline) {
|
|
35
|
+
if (underline.mode === "off")
|
|
36
|
+
return [];
|
|
37
|
+
return [
|
|
38
|
+
{ name: "text-decoration", value: "underline" },
|
|
39
|
+
{ name: "text-decoration-color", value: underline.color },
|
|
40
|
+
{ name: "text-decoration-thickness", value: `${compactNumber(underline.thicknessPx)}px` },
|
|
41
|
+
{ name: "text-underline-offset", value: `${compactNumber(underline.offsetPx)}px` },
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The glyph body and its outline, as ordered Paint.
|
|
46
|
+
*
|
|
47
|
+
* An outline belongs outside the letter. `-webkit-text-stroke` cannot put it there: it centres the
|
|
48
|
+
* stroke on the glyph edge, so half the width is always inside. Declaring the Paint hands placement
|
|
49
|
+
* to the renderer, which keeps the authored width wholly outside while the body preserves the
|
|
50
|
+
* original letterform.
|
|
51
|
+
*
|
|
52
|
+
* A caption with no outline keeps its plain fill, which is one element and one paint rather than
|
|
53
|
+
* two, and is what most captions are.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* CSS blur is a radius; a Gaussian blur is a deviation. The radius covers about two deviations, so
|
|
57
|
+
* the same number means twice the cloud when it moves from one to the other. Authors tuned these
|
|
58
|
+
* against the radius, and the outline is what moved, not the shadow.
|
|
59
|
+
*/
|
|
60
|
+
function deviationFromBlurRadius(radiusPx) {
|
|
61
|
+
return radiusPx / 2;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The soft effects, ordered from the back forward.
|
|
65
|
+
*
|
|
66
|
+
* `text-shadow` paints its list front to back — the first shadow named is the one on top — while
|
|
67
|
+
* Paint layers are drawn in the order they are declared. So this reverses: the far end of a long
|
|
68
|
+
* shadow first, then the glow, then the drop shadow nearest the letter.
|
|
69
|
+
*/
|
|
70
|
+
function softPaints(paint) {
|
|
71
|
+
const layers = [];
|
|
72
|
+
if (paint.longShadow.opacity > 0 && paint.longShadow.distancePx > 0) {
|
|
73
|
+
const steps = Math.min(32, Math.max(1, Math.ceil(paint.longShadow.distancePx)));
|
|
74
|
+
const radians = paint.longShadow.angleDeg * Math.PI / 180;
|
|
75
|
+
for (let step = steps; step >= 1; step -= 1) {
|
|
76
|
+
const distance = paint.longShadow.distancePx * step / steps;
|
|
77
|
+
layers.push({
|
|
78
|
+
kind: "shadow",
|
|
79
|
+
paint: { kind: "solid", color: alphaColor(paint.longShadow.color, paint.longShadow.opacity) },
|
|
80
|
+
offsetX: Math.cos(radians) * distance,
|
|
81
|
+
offsetY: Math.sin(radians) * distance,
|
|
82
|
+
blurPx: 0,
|
|
83
|
+
spreadPx: 0,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (paint.glow.opacity > 0) {
|
|
88
|
+
layers.push({
|
|
89
|
+
kind: "glow",
|
|
90
|
+
paint: { kind: "solid", color: alphaColor(paint.glow.color, paint.glow.opacity) },
|
|
91
|
+
blurPx: deviationFromBlurRadius(paint.glow.blurPx),
|
|
92
|
+
spreadPx: paint.glow.spreadPx,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (paint.shadow.opacity > 0) {
|
|
96
|
+
layers.push({
|
|
97
|
+
kind: "shadow",
|
|
98
|
+
paint: { kind: "solid", color: alphaColor(paint.shadow.color, paint.shadow.opacity) },
|
|
99
|
+
offsetX: paint.shadow.offsetXPx,
|
|
100
|
+
offsetY: paint.shadow.offsetYPx,
|
|
101
|
+
blurPx: deviationFromBlurRadius(paint.shadow.blurPx),
|
|
102
|
+
spreadPx: paint.shadow.spreadPx,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return layers;
|
|
106
|
+
}
|
|
107
|
+
function glyphPaints(paint) {
|
|
108
|
+
if (paint.stroke.widthPx === 0 && paint.shadow.spreadPx === 0 && paint.glow.spreadPx === 0)
|
|
109
|
+
return undefined;
|
|
110
|
+
const fill = paint.gradient === undefined
|
|
111
|
+
? { kind: "solid", color: paint.fill }
|
|
112
|
+
: {
|
|
113
|
+
kind: "linear-gradient",
|
|
114
|
+
angleDeg: paint.gradient.angleDeg,
|
|
115
|
+
stops: [
|
|
116
|
+
{ offset: 0, color: paint.gradient.from, opacity: 1 },
|
|
117
|
+
{ offset: 1, color: paint.gradient.to, opacity: 1 },
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
// Back to front: the soft effects fall behind the outline they are cast from, the outline sits
|
|
121
|
+
// outside the letter, and the body sits over its own outline.
|
|
122
|
+
return [
|
|
123
|
+
...softPaints(paint),
|
|
124
|
+
...(paint.stroke.widthPx === 0 ? [] : [{
|
|
125
|
+
kind: "stroke",
|
|
126
|
+
placement: "outside",
|
|
127
|
+
widthPx: paint.stroke.widthPx,
|
|
128
|
+
paint: { kind: "solid", color: paint.stroke.color },
|
|
129
|
+
}]),
|
|
130
|
+
{ kind: "fill", paint: fill },
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
/** The Paint fields of a text element, absent rather than empty when there is no outline. */
|
|
134
|
+
function glyphPaintFields(paint) {
|
|
135
|
+
const paints = glyphPaints(paint);
|
|
136
|
+
return paints === undefined ? {} : { paints };
|
|
137
|
+
}
|
|
138
|
+
function glyphStyle(parameters, paint, underline) {
|
|
139
|
+
const shadows = [];
|
|
140
|
+
if (paint.shadow.opacity > 0) {
|
|
141
|
+
shadows.push([
|
|
142
|
+
`${compactNumber(paint.shadow.offsetXPx)}px`,
|
|
143
|
+
`${compactNumber(paint.shadow.offsetYPx)}px`,
|
|
144
|
+
`${compactNumber(paint.shadow.blurPx)}px`,
|
|
145
|
+
alphaColor(paint.shadow.color, paint.shadow.opacity),
|
|
146
|
+
].join(" "));
|
|
147
|
+
}
|
|
148
|
+
if (paint.glow.opacity > 0) {
|
|
149
|
+
shadows.push(`0 0 ${compactNumber(paint.glow.blurPx)}px ${alphaColor(paint.glow.color, paint.glow.opacity)}`);
|
|
150
|
+
}
|
|
151
|
+
if (paint.longShadow.opacity > 0 && paint.longShadow.distancePx > 0) {
|
|
152
|
+
const steps = Math.min(32, Math.max(1, Math.ceil(paint.longShadow.distancePx)));
|
|
153
|
+
const radians = paint.longShadow.angleDeg * Math.PI / 180;
|
|
154
|
+
for (let step = 1; step <= steps; step += 1) {
|
|
155
|
+
const distance = paint.longShadow.distancePx * step / steps;
|
|
156
|
+
shadows.push([
|
|
157
|
+
`${compactNumber(Math.cos(radians) * distance)}px`,
|
|
158
|
+
`${compactNumber(Math.sin(radians) * distance)}px`,
|
|
159
|
+
"0",
|
|
160
|
+
alphaColor(paint.longShadow.color, paint.longShadow.opacity),
|
|
161
|
+
].join(" "));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// An outlined caption carries its body and outline as Paint instead, so the fill is spelled once
|
|
165
|
+
// — there, not here — and the two cannot disagree.
|
|
166
|
+
const painted = glyphPaints(paint) !== undefined;
|
|
167
|
+
return [
|
|
168
|
+
...(painted ? [] : [{ name: "color", value: paint.fill }]),
|
|
169
|
+
...typographyStyle(parameters),
|
|
170
|
+
{ name: "min-width", value: "0" },
|
|
171
|
+
{ name: "opacity", value: paint.opacity },
|
|
172
|
+
{ name: "white-space", value: "normal" },
|
|
173
|
+
...(painted || paint.gradient === undefined ? [] : [
|
|
174
|
+
{ name: "background-image", value: `linear-gradient(${compactNumber(paint.gradient.angleDeg)}deg,${paint.gradient.from},${paint.gradient.to})` },
|
|
175
|
+
{ name: "background-clip", value: "text" },
|
|
176
|
+
{ name: "-webkit-background-clip", value: "text" },
|
|
177
|
+
{ name: "-webkit-text-fill-color", value: "transparent" },
|
|
178
|
+
]),
|
|
179
|
+
// An outlined caption casts its shadows as Paint, behind the outline. Left here as well they
|
|
180
|
+
// would be cast by the body alone, and so land on top of the ring they are supposed to be under.
|
|
181
|
+
...(painted || shadows.length === 0 ? [] : [{ name: "text-shadow", value: shadows.join(",") }]),
|
|
182
|
+
...(underline === undefined ? [] : underlineStyle(underline)),
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
function transparentGlyphStyle(parameters, underline) {
|
|
186
|
+
return [
|
|
187
|
+
{ name: "color", value: "#00000000" },
|
|
188
|
+
...typographyStyle(parameters),
|
|
189
|
+
{ name: "min-width", value: "0" },
|
|
190
|
+
{ name: "white-space", value: "normal" },
|
|
191
|
+
...(underline === undefined ? [] : underlineStyle(underline)),
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
function styleIdentity(style) {
|
|
195
|
+
return JSON.stringify(style);
|
|
196
|
+
}
|
|
197
|
+
function animationFrom(durationFrames, offsets, styleAt) {
|
|
198
|
+
const keyframes = [...new Set([0, durationFrames, ...offsets])]
|
|
199
|
+
.filter((frame) => Number.isSafeInteger(frame) && frame >= 0 && frame <= durationFrames)
|
|
200
|
+
.sort((left, right) => left - right)
|
|
201
|
+
.map((atFrame) => ({ atFrame, style: styleAt(atFrame) }));
|
|
202
|
+
if (new Set(keyframes.map((keyframe) => styleIdentity(keyframe.style))).size === 1)
|
|
203
|
+
return undefined;
|
|
204
|
+
return { keyframes };
|
|
205
|
+
}
|
|
206
|
+
function stepOffsets(...boundaries) {
|
|
207
|
+
return boundaries.flatMap((boundary) => [boundary - 1, boundary]);
|
|
208
|
+
}
|
|
209
|
+
function easeOut(value) {
|
|
210
|
+
return 1 - Math.pow(1 - value, 3);
|
|
211
|
+
}
|
|
212
|
+
function easeOutBack(value) {
|
|
213
|
+
const c1 = 1.3;
|
|
214
|
+
const c3 = c1 + 1;
|
|
215
|
+
return 1 + c3 * Math.pow(value - 1, 3) + c1 * Math.pow(value - 1, 2);
|
|
216
|
+
}
|
|
217
|
+
function easeOutBounce(value) {
|
|
218
|
+
let x = value;
|
|
219
|
+
const n1 = 7.5625;
|
|
220
|
+
const d1 = 2.75;
|
|
221
|
+
if (x < 1 / d1)
|
|
222
|
+
return n1 * x * x;
|
|
223
|
+
if (x < 2 / d1)
|
|
224
|
+
return n1 * (x -= 1.5 / d1) * x + 0.75;
|
|
225
|
+
if (x < 2.5 / d1)
|
|
226
|
+
return n1 * (x -= 2.25 / d1) * x + 0.9375;
|
|
227
|
+
return n1 * (x -= 2.625 / d1) * x + 0.984375;
|
|
228
|
+
}
|
|
229
|
+
function easeOutElastic(value) {
|
|
230
|
+
if (value === 0 || value === 1)
|
|
231
|
+
return value;
|
|
232
|
+
return Math.pow(2, -10 * value) * Math.sin((value * 10 - 0.75) * (2 * Math.PI / 3)) + 1;
|
|
233
|
+
}
|
|
234
|
+
const neutralMotion = { opacity: 1, transform: "none", filter: "none", clipPath: "inset(0% 0% 0% 0%)" };
|
|
235
|
+
/**
|
|
236
|
+
* Whether a motion needs `clip-path` at all.
|
|
237
|
+
*
|
|
238
|
+
* `inset(0% 0% 0% 0%)` reads as a no-op and is not one: it clips the element and everything inside it
|
|
239
|
+
* to its border box. These boxes carry no padding, so their border box is exactly the glyph advance —
|
|
240
|
+
* and an outline is painted outside that, by a filter that contributes nothing to layout. Emitted as
|
|
241
|
+
* the resting value of every motion, it cut the outline off on all four sides, along with the glow,
|
|
242
|
+
* the drop shadow and the long shadow, which reach further still.
|
|
243
|
+
*
|
|
244
|
+
* A wipe is the only motion that has anything to clip. Every keyframe of one animation has to declare
|
|
245
|
+
* the same properties, so this is decided once per animation rather than per frame.
|
|
246
|
+
*/
|
|
247
|
+
function wipes(kind) {
|
|
248
|
+
return kind === "wipe-left" || kind === "wipe-right" || kind === "wipe-up" || kind === "wipe-down";
|
|
249
|
+
}
|
|
250
|
+
function motionSnapshot(kind, progress, distancePx, startScale) {
|
|
251
|
+
const value = clamp(progress, 0, 1);
|
|
252
|
+
const eased = easeOut(value);
|
|
253
|
+
const state = (opacity, transform = "none", filter = "none", clipPath = neutralMotion.clipPath) => ({ opacity, transform, filter, clipPath });
|
|
254
|
+
const scaleFrom = (fallback, motionProgress) => `scale(${compactNumber((startScale ?? fallback) + (1 - (startScale ?? fallback)) * motionProgress)})`;
|
|
255
|
+
const optionalScale = () => startScale === undefined ? "" : ` ${scaleFrom(1, eased)}`;
|
|
256
|
+
if (kind === "none")
|
|
257
|
+
return neutralMotion;
|
|
258
|
+
if (kind === "fade")
|
|
259
|
+
return state(eased, startScale === undefined ? "none" : scaleFrom(1, eased));
|
|
260
|
+
if (kind === "blur-in")
|
|
261
|
+
return state(eased, scaleFrom(0.96, eased), `blur(${compactNumber((1 - eased) * 16)}px)`);
|
|
262
|
+
if (kind === "pop")
|
|
263
|
+
return state(eased, scaleFrom(0.6, easeOutBack(value)));
|
|
264
|
+
if (kind === "scale")
|
|
265
|
+
return state(eased, scaleFrom(0.2, eased));
|
|
266
|
+
if (kind === "spring") {
|
|
267
|
+
const settled = value >= 1 ? 1 : 1 - Math.exp(-5 * value) * Math.cos(10 * value);
|
|
268
|
+
return state(eased, scaleFrom(0.72, settled));
|
|
269
|
+
}
|
|
270
|
+
if (kind === "bounce")
|
|
271
|
+
return state(eased, scaleFrom(0, easeOutBounce(value)));
|
|
272
|
+
if (kind === "elastic")
|
|
273
|
+
return state(eased, scaleFrom(0, easeOutElastic(value)));
|
|
274
|
+
if (kind === "stamp")
|
|
275
|
+
return state(eased, scaleFrom(1.35, easeOutBack(value)));
|
|
276
|
+
if (kind === "tilt")
|
|
277
|
+
return state(eased, `rotate(${compactNumber((1 - eased) * -8)}deg) ${scaleFrom(0.94, eased)}`);
|
|
278
|
+
if (kind === "zoom-blur")
|
|
279
|
+
return state(eased, scaleFrom(1.18, eased), `blur(${compactNumber((1 - eased) * 18)}px)`);
|
|
280
|
+
if (kind === "flip-x")
|
|
281
|
+
return state(eased, `perspective(600px) rotateX(${compactNumber((1 - eased) * 88)}deg) ${scaleFrom(0.92, eased)}`);
|
|
282
|
+
if (kind === "flip-y")
|
|
283
|
+
return state(eased, `perspective(600px) rotateY(${compactNumber((1 - eased) * -88)}deg) ${scaleFrom(0.92, eased)}`);
|
|
284
|
+
if (kind === "spin")
|
|
285
|
+
return state(eased, `rotate(${compactNumber((1 - eased) * -180)}deg) ${scaleFrom(0.55, eased)}`);
|
|
286
|
+
if (kind === "squash")
|
|
287
|
+
return state(eased, `scaleX(${compactNumber(1.28 - 0.28 * easeOutBack(value))}) scaleY(${compactNumber(0.48 + 0.52 * easeOutBack(value))})${optionalScale()}`);
|
|
288
|
+
if (kind === "stretch")
|
|
289
|
+
return state(eased, `scaleX(${compactNumber(0.5 + 0.5 * easeOutBack(value))}) scaleY(${compactNumber(1.35 - 0.35 * easeOutBack(value))})${optionalScale()}`);
|
|
290
|
+
const hidden = compactNumber((1 - eased) * 100);
|
|
291
|
+
const wipeTransform = startScale === undefined ? "none" : scaleFrom(1, eased);
|
|
292
|
+
if (kind === "wipe-left")
|
|
293
|
+
return state(1, wipeTransform, "none", `inset(0% 0% 0% ${hidden}%)`);
|
|
294
|
+
if (kind === "wipe-right")
|
|
295
|
+
return state(1, wipeTransform, "none", `inset(0% ${hidden}% 0% 0%)`);
|
|
296
|
+
if (kind === "wipe-up")
|
|
297
|
+
return state(1, wipeTransform, "none", `inset(0% 0% ${hidden}% 0%)`);
|
|
298
|
+
if (kind === "wipe-down")
|
|
299
|
+
return state(1, wipeTransform, "none", `inset(${hidden}% 0% 0% 0%)`);
|
|
300
|
+
const remaining = compactNumber((1 - value) * distancePx);
|
|
301
|
+
const transform = kind === "slide-left" ? `translateX(-${remaining}px)`
|
|
302
|
+
: kind === "slide-right" ? `translateX(${remaining}px)`
|
|
303
|
+
: kind === "slide-up" ? `translateY(-${remaining}px)`
|
|
304
|
+
: `translateY(${remaining}px)`;
|
|
305
|
+
return state(eased, `${transform}${optionalScale()}`);
|
|
306
|
+
}
|
|
307
|
+
function cueEntranceSnapshot(parameters, progress) {
|
|
308
|
+
const value = clamp(progress, 0, 1);
|
|
309
|
+
const authored = motionSnapshot(parameters.motion.cueEnter, value, parameters.motion.slideDistancePx, parameters.motion.cueEnterStartScale);
|
|
310
|
+
const snapshot = parameters.motion.cueEnter !== "spring" ? authored : (() => {
|
|
311
|
+
const start = parameters.motion.cueEnterStartScale ?? 0.72;
|
|
312
|
+
const scale = value <= 0.5
|
|
313
|
+
? start + (1.05 - start) * value / 0.5
|
|
314
|
+
: value <= 0.75
|
|
315
|
+
? 1.05 + (0.95 - 1.05) * (value - 0.5) / 0.25
|
|
316
|
+
: 0.95 + (1 - 0.95) * (value - 0.75) / 0.25;
|
|
317
|
+
return { ...authored, opacity: 1, transform: `scale(${compactNumber(scale)})` };
|
|
318
|
+
})();
|
|
319
|
+
return snapshot;
|
|
320
|
+
}
|
|
321
|
+
function snapshotStyle(snapshot, clipped = true) {
|
|
322
|
+
return [
|
|
323
|
+
{ name: "opacity", value: snapshot.opacity },
|
|
324
|
+
{ name: "transform", value: snapshot.transform },
|
|
325
|
+
{ name: "filter", value: snapshot.filter },
|
|
326
|
+
...(clipped ? [{ name: "clip-path", value: snapshot.clipPath }] : []),
|
|
327
|
+
];
|
|
328
|
+
}
|
|
329
|
+
function transitionOffsets(start, frames, kind) {
|
|
330
|
+
if (frames === 0)
|
|
331
|
+
return stepOffsets(start);
|
|
332
|
+
const samples = ["spring", "bounce", "elastic"].includes(kind) ? Math.min(frames, 12) : Math.min(frames, 4);
|
|
333
|
+
return Array.from({ length: samples + 1 }, (_, index) => start + Math.round(frames * index / samples));
|
|
334
|
+
}
|
|
335
|
+
function cueAnimation(parameters, durationFrames) {
|
|
336
|
+
const enterFrames = parameters.motion.cueEnter === "none"
|
|
337
|
+
? 0 : Math.min(parameters.motion.cueEnterFrames, Math.floor(durationFrames / 2));
|
|
338
|
+
const exitFrames = parameters.motion.cueExit === "none"
|
|
339
|
+
? 0 : Math.min(parameters.motion.cueExitFrames, Math.floor(durationFrames / 2));
|
|
340
|
+
const offsets = [
|
|
341
|
+
...transitionOffsets(0, enterFrames, parameters.motion.cueEnter),
|
|
342
|
+
...transitionOffsets(durationFrames - exitFrames, exitFrames, parameters.motion.cueExit),
|
|
343
|
+
];
|
|
344
|
+
const clipped = wipes(parameters.motion.cueEnter) || wipes(parameters.motion.cueExit);
|
|
345
|
+
return animationFrom(durationFrames, offsets, (frame) => {
|
|
346
|
+
const enterProgress = enterFrames === 0 ? 1 : clamp(frame / enterFrames, 0, 1);
|
|
347
|
+
const exitProgress = exitFrames === 0 ? 1 : clamp((durationFrames - frame) / exitFrames, 0, 1);
|
|
348
|
+
const enter = cueEntranceSnapshot(parameters, enterProgress);
|
|
349
|
+
const exit = motionSnapshot(parameters.motion.cueExit, exitProgress, parameters.motion.slideDistancePx);
|
|
350
|
+
return snapshotStyle(enterProgress < 1 ? enter : exit, clipped);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
function atomLifecycleAnimation(parameters, startFrame, endFrame, durationFrames) {
|
|
354
|
+
const shouldWait = parameters.motion.atomReveal === "on-start" || parameters.motion.atomEnter !== "none";
|
|
355
|
+
if (!shouldWait && parameters.motion.atomExit === "none")
|
|
356
|
+
return undefined;
|
|
357
|
+
const enterFrames = parameters.motion.atomEnter === "none" ? 0
|
|
358
|
+
: Math.min(parameters.motion.atomEnterFrames, Math.max(0, endFrame - startFrame));
|
|
359
|
+
const exitFrames = parameters.motion.atomExit === "none" ? 0
|
|
360
|
+
: Math.min(parameters.motion.atomExitFrames, Math.max(0, endFrame - startFrame - enterFrames));
|
|
361
|
+
const offsets = [
|
|
362
|
+
...transitionOffsets(startFrame, enterFrames, parameters.motion.atomEnter),
|
|
363
|
+
...transitionOffsets(endFrame - exitFrames, exitFrames, parameters.motion.atomExit),
|
|
364
|
+
...stepOffsets(endFrame),
|
|
365
|
+
];
|
|
366
|
+
const clipped = wipes(parameters.motion.atomEnter) || wipes(parameters.motion.atomExit);
|
|
367
|
+
return animationFrom(durationFrames, offsets, (frame) => {
|
|
368
|
+
if (frame < startFrame)
|
|
369
|
+
return shouldWait ? [{ name: "opacity", value: 0 }] : snapshotStyle(neutralMotion, clipped);
|
|
370
|
+
if (frame >= endFrame && parameters.motion.atomExit !== "none") {
|
|
371
|
+
return snapshotStyle(motionSnapshot(parameters.motion.atomExit, 0, parameters.motion.slideDistancePx), clipped);
|
|
372
|
+
}
|
|
373
|
+
const enterProgress = enterFrames === 0 ? 1 : clamp((frame - startFrame) / enterFrames, 0, 1);
|
|
374
|
+
const exitProgress = exitFrames === 0 ? 1 : clamp((endFrame - frame) / exitFrames, 0, 1);
|
|
375
|
+
if (enterProgress < 1)
|
|
376
|
+
return snapshotStyle(motionSnapshot(parameters.motion.atomEnter, enterProgress, parameters.motion.slideDistancePx), clipped);
|
|
377
|
+
return snapshotStyle(motionSnapshot(parameters.motion.atomExit, exitProgress, parameters.motion.slideDistancePx), clipped);
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
function activationStepAnimation(mode, startFrame, endFrame, durationFrames) {
|
|
381
|
+
return animationFrom(durationFrames, stepOffsets(startFrame, endFrame), (frame) => {
|
|
382
|
+
const active = mode === "trail"
|
|
383
|
+
? frame >= startFrame
|
|
384
|
+
: frame >= startFrame && (frame < endFrame || (endFrame === durationFrames && frame === durationFrames));
|
|
385
|
+
return [{ name: "opacity", value: active ? 1 : 0 }];
|
|
386
|
+
}) ?? { keyframes: [
|
|
387
|
+
{ atFrame: 0, style: [{ name: "opacity", value: 1 }] },
|
|
388
|
+
{ atFrame: durationFrames, style: [{ name: "opacity", value: 1 }] },
|
|
389
|
+
] };
|
|
390
|
+
}
|
|
391
|
+
function wipeClip(progress, direction) {
|
|
392
|
+
const hidden = compactNumber((1 - clamp(progress, 0, 1)) * 100);
|
|
393
|
+
return direction === "rtl" ? `inset(0 0 0 ${hidden}%)` : `inset(0 ${hidden}% 0 0)`;
|
|
394
|
+
}
|
|
395
|
+
function karaokeWipeAnimation(parameters, mode, startFrame, endFrame, durationFrames) {
|
|
396
|
+
if (mode === "current" && endFrame - startFrame <= 1) {
|
|
397
|
+
return activationStepAnimation(mode, startFrame, endFrame, durationFrames);
|
|
398
|
+
}
|
|
399
|
+
const revealEnd = mode === "current" ? endFrame - 1 : endFrame;
|
|
400
|
+
return animationFrom(durationFrames, [startFrame, revealEnd, endFrame], (frame) => {
|
|
401
|
+
let progress = 0;
|
|
402
|
+
if (frame >= startFrame && frame <= revealEnd) {
|
|
403
|
+
progress = revealEnd === startFrame ? 1 : (frame - startFrame) / (revealEnd - startFrame);
|
|
404
|
+
}
|
|
405
|
+
else if (mode === "trail" && frame > revealEnd) {
|
|
406
|
+
progress = 1;
|
|
407
|
+
}
|
|
408
|
+
return [{ name: "clip-path", value: wipeClip(progress, parameters.layout.direction) }];
|
|
409
|
+
}) ?? { keyframes: [
|
|
410
|
+
{ atFrame: 0, style: [{ name: "clip-path", value: wipeClip(1, parameters.layout.direction) }] },
|
|
411
|
+
{ atFrame: durationFrames, style: [{ name: "clip-path", value: wipeClip(1, parameters.layout.direction) }] },
|
|
412
|
+
] };
|
|
413
|
+
}
|
|
414
|
+
function activeResponseAnimation(parameters, startFrame, endFrame, durationFrames) {
|
|
415
|
+
const response = parameters.motion.activeResponse;
|
|
416
|
+
if (response === "none" || endFrame <= startFrame)
|
|
417
|
+
return undefined;
|
|
418
|
+
if (response === "scale") {
|
|
419
|
+
return animationFrom(durationFrames, stepOffsets(startFrame, endFrame), (frame) => {
|
|
420
|
+
const active = frame >= startFrame && (frame < endFrame || (endFrame === durationFrames && frame === durationFrames));
|
|
421
|
+
return [{ name: "transform", value: `scale(${compactNumber(active ? parameters.motion.activeScale : 1)})` }];
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
const frames = Math.min(parameters.motion.activeResponseFrames, Math.max(1, endFrame - startFrame));
|
|
425
|
+
const clipped = wipes(response);
|
|
426
|
+
return animationFrom(durationFrames, transitionOffsets(startFrame, frames, response), (frame) => {
|
|
427
|
+
if (frame < startFrame || frame > startFrame + frames)
|
|
428
|
+
return snapshotStyle(neutralMotion, clipped);
|
|
429
|
+
const progress = clamp((frame - startFrame) / frames, 0, 1);
|
|
430
|
+
if (response === "pop" || response === "spring") {
|
|
431
|
+
const amplitude = parameters.motion.activeScale - 1;
|
|
432
|
+
const responseScale = response === "pop"
|
|
433
|
+
? 1 + amplitude * Math.sin(Math.PI * progress)
|
|
434
|
+
: 1 + amplitude * Math.exp(-4 * progress) * Math.sin(12 * progress);
|
|
435
|
+
return [{ name: "transform", value: `scale(${compactNumber(responseScale)})` }];
|
|
436
|
+
}
|
|
437
|
+
return snapshotStyle(motionSnapshot(response, progress, parameters.motion.slideDistancePx), clipped);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
function loopAnimation(parameters, durationFrames, activeWindow) {
|
|
441
|
+
if (parameters.motion.loop === "none")
|
|
442
|
+
return undefined;
|
|
443
|
+
const period = parameters.motion.loopPeriodFrames;
|
|
444
|
+
const start = activeWindow?.start ?? 0;
|
|
445
|
+
const end = activeWindow?.end ?? durationFrames;
|
|
446
|
+
const offsets = [...stepOffsets(start, end)];
|
|
447
|
+
for (let frame = start; frame <= end; frame += Math.max(1, Math.round(period / 4)))
|
|
448
|
+
offsets.push(frame);
|
|
449
|
+
return animationFrom(durationFrames, offsets, (frame) => {
|
|
450
|
+
const active = frame >= start && (frame < end || (end === durationFrames && frame === durationFrames));
|
|
451
|
+
if (!active)
|
|
452
|
+
return parameters.motion.loop === "glow-pulse"
|
|
453
|
+
? [{ name: "filter", value: "none" }]
|
|
454
|
+
: parameters.motion.loop === "flicker" ? [{ name: "opacity", value: 1 }]
|
|
455
|
+
: [{ name: "transform", value: "none" }];
|
|
456
|
+
const phase = 2 * Math.PI * ((frame - start) % period) / period;
|
|
457
|
+
if (parameters.motion.loop === "shake") {
|
|
458
|
+
return [{ name: "transform", value: `translateX(${compactNumber(Math.sin(phase * 2) * 3 * parameters.motion.loopIntensity)}px)` }];
|
|
459
|
+
}
|
|
460
|
+
if (parameters.motion.loop === "wobble") {
|
|
461
|
+
return [{ name: "transform", value: `rotate(${compactNumber(Math.sin(phase) * 2.5 * parameters.motion.loopIntensity)}deg)` }];
|
|
462
|
+
}
|
|
463
|
+
if (parameters.motion.loop === "breathe") {
|
|
464
|
+
return [{ name: "transform", value: `scale(${compactNumber(1 + Math.sin(phase) * 0.035 * parameters.motion.loopIntensity)})` }];
|
|
465
|
+
}
|
|
466
|
+
if (parameters.motion.loop === "float") {
|
|
467
|
+
return [{ name: "transform", value: `translateY(${compactNumber(Math.sin(phase) * 3.2 * parameters.motion.loopIntensity)}px)` }];
|
|
468
|
+
}
|
|
469
|
+
if (parameters.motion.loop === "pulse") {
|
|
470
|
+
return [{ name: "transform", value: `scale(${compactNumber(1 + (Math.sin(phase) + 1) * 0.0375 * parameters.motion.loopIntensity)})` }];
|
|
471
|
+
}
|
|
472
|
+
if (parameters.motion.loop === "flicker") {
|
|
473
|
+
return [{ name: "opacity", value: clamp(1 - (Math.sin(phase * 5) + 1) * 0.11 * parameters.motion.loopIntensity, 0.15, 1) }];
|
|
474
|
+
}
|
|
475
|
+
const strength = (0.35 + 0.65 * (Math.sin(phase) + 1) / 2) * parameters.motion.loopIntensity;
|
|
476
|
+
const paint = activeWindow === undefined ? parameters.basePaint : parameters.activePaint;
|
|
477
|
+
const color = paint.glow.opacity > 0 ? paint.glow.color : paint.fill;
|
|
478
|
+
const opacity = paint.glow.opacity > 0 ? paint.glow.opacity : 0.65;
|
|
479
|
+
const blur = paint.glow.blurPx > 0 ? paint.glow.blurPx : 8;
|
|
480
|
+
return [{ name: "filter", value: `drop-shadow(0 0 ${compactNumber(blur * strength)}px ${alphaColor(color, opacity)})` }];
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
function activeBoxAnimation(parameters, startFrame, endFrame, durationFrames, mode = parameters.activeBox.mode === "off" ? "current" : parameters.activeBox.mode) {
|
|
484
|
+
const box = parameters.activeBox;
|
|
485
|
+
const logicalEnd = mode === "trail" ? durationFrames : endFrame;
|
|
486
|
+
const enterFrames = box.enter === "none" ? 0 : Math.min(box.transitionFrames, Math.max(0, logicalEnd - startFrame));
|
|
487
|
+
const exitFrames = box.exit === "none" ? 0 : Math.min(box.transitionFrames, Math.max(0, logicalEnd - startFrame));
|
|
488
|
+
const offsets = [
|
|
489
|
+
...stepOffsets(startFrame, logicalEnd),
|
|
490
|
+
...transitionOffsets(startFrame, enterFrames, box.enter),
|
|
491
|
+
...transitionOffsets(logicalEnd - exitFrames, exitFrames, box.exit),
|
|
492
|
+
];
|
|
493
|
+
return animationFrom(durationFrames, offsets, (frame) => {
|
|
494
|
+
if (frame < startFrame || frame > logicalEnd || (frame === logicalEnd && logicalEnd !== durationFrames)) {
|
|
495
|
+
return [{ name: "opacity", value: 0 }, { name: "transform", value: "scale(0.82)" }];
|
|
496
|
+
}
|
|
497
|
+
const enterProgress = enterFrames === 0 ? 1 : clamp((frame - startFrame) / enterFrames, 0, 1);
|
|
498
|
+
const exitProgress = exitFrames === 0 ? 1 : clamp((logicalEnd - frame) / exitFrames, 0, 1);
|
|
499
|
+
const kind = enterProgress < 1 ? box.enter : box.exit;
|
|
500
|
+
return snapshotStyle(motionSnapshot(kind, Math.min(enterProgress, exitProgress), parameters.motion.slideDistancePx), wipes(box.enter) || wipes(box.exit));
|
|
501
|
+
}) ?? { keyframes: [
|
|
502
|
+
{ atFrame: 0, style: [{ name: "opacity", value: 1 }] },
|
|
503
|
+
{ atFrame: durationFrames, style: [{ name: "opacity", value: 1 }] },
|
|
504
|
+
] };
|
|
505
|
+
}
|
|
506
|
+
function anchorTransform(parameters) {
|
|
507
|
+
const x = parameters.placement.anchorX === "left" ? 0 : parameters.placement.anchorX === "center" ? -50 : -100;
|
|
508
|
+
const y = parameters.placement.anchorY === "top" ? 0 : parameters.placement.anchorY === "center" ? -50 : -100;
|
|
509
|
+
return x === 0 && y === 0 ? undefined : `translate(${x}%,${y}%)`;
|
|
510
|
+
}
|
|
511
|
+
function trackedPlacementAnimation(parameters, frames) {
|
|
512
|
+
if (frames.length === 0)
|
|
513
|
+
throw new Error("Fine Caption tracked placement has no Frames");
|
|
514
|
+
const anchor = anchorTransform(parameters);
|
|
515
|
+
const transform = (frame) => {
|
|
516
|
+
const xPx = frame === null ? 0 : frame.xPx + frame.widthPx / 2;
|
|
517
|
+
const yPx = frame === null ? 0 : frame.yPx;
|
|
518
|
+
return `translate(${compactNumber(xPx)}px,${compactNumber(yPx)}px)${anchor === undefined ? "" : ` ${anchor}`}`;
|
|
519
|
+
};
|
|
520
|
+
const keyframes = frames.map((frame, atFrame) => ({
|
|
521
|
+
atFrame,
|
|
522
|
+
style: [
|
|
523
|
+
{ name: "transform", value: transform(frame) },
|
|
524
|
+
{ name: "opacity", value: frame === null ? 0 : 1 },
|
|
525
|
+
],
|
|
526
|
+
}));
|
|
527
|
+
keyframes.push({
|
|
528
|
+
atFrame: frames.length,
|
|
529
|
+
style: [
|
|
530
|
+
{ name: "transform", value: transform(frames.at(-1)) },
|
|
531
|
+
{ name: "opacity", value: frames.at(-1) === null ? 0 : 1 },
|
|
532
|
+
],
|
|
533
|
+
});
|
|
534
|
+
return { keyframes };
|
|
535
|
+
}
|
|
536
|
+
function structuralRowStarts(atoms, maxWordsPerLine) {
|
|
537
|
+
const starts = new Set();
|
|
538
|
+
let wordsOnRow = 0;
|
|
539
|
+
for (const [index, atom] of atoms.entries()) {
|
|
540
|
+
if (index === 0 || (maxWordsPerLine !== undefined && wordsOnRow > 0 && wordsOnRow + atom.wordIds.length > maxWordsPerLine)) {
|
|
541
|
+
starts.add(index);
|
|
542
|
+
wordsOnRow = 0;
|
|
543
|
+
}
|
|
544
|
+
wordsOnRow += atom.wordIds.length;
|
|
545
|
+
}
|
|
546
|
+
return starts;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Acoustic Word windows may overlap. Preserve those measurements in the
|
|
550
|
+
* Timeline, but give every current-only visual state one unambiguous
|
|
551
|
+
* owner: as soon as the next authored unit starts, the previous one stops.
|
|
552
|
+
*/
|
|
553
|
+
function exclusiveActivationFrames(atoms, atomFrames) {
|
|
554
|
+
return new Map(atoms.map((atom, index) => {
|
|
555
|
+
const timing = atomFrames.get(atom.id);
|
|
556
|
+
if (timing === undefined)
|
|
557
|
+
throw new Error(`Fine Caption is missing timing for Atom ${atom.id}`);
|
|
558
|
+
const next = atoms[index + 1];
|
|
559
|
+
const nextStart = next === undefined ? undefined : atomFrames.get(next.id)?.start;
|
|
560
|
+
if (next !== undefined && nextStart === undefined) {
|
|
561
|
+
throw new Error(`Fine Caption is missing timing for Atom ${next.id}`);
|
|
562
|
+
}
|
|
563
|
+
return [atom.id, {
|
|
564
|
+
start: timing.start,
|
|
565
|
+
end: nextStart === undefined ? timing.end : Math.min(timing.end, nextStart),
|
|
566
|
+
}];
|
|
567
|
+
}));
|
|
568
|
+
}
|
|
569
|
+
function cueElements(atoms, atomFrames, parameters, wordText, durationFrames, styleId, within, trackedPlacement) {
|
|
570
|
+
const elements = [];
|
|
571
|
+
let order = 0;
|
|
572
|
+
const push = (element) => {
|
|
573
|
+
elements.push({ ...element, order });
|
|
574
|
+
order += 1;
|
|
575
|
+
};
|
|
576
|
+
const placementAnimation = trackedPlacement === undefined
|
|
577
|
+
? undefined
|
|
578
|
+
: trackedPlacementAnimation(parameters, trackedPlacement.frames);
|
|
579
|
+
const transform = placementAnimation === undefined
|
|
580
|
+
? anchorTransform(parameters)
|
|
581
|
+
: (() => {
|
|
582
|
+
const value = placementAnimation.keyframes[0]?.style.find((declaration) => declaration.name === "transform")?.value;
|
|
583
|
+
if (typeof value !== "string")
|
|
584
|
+
throw new Error("Fine Caption tracked placement has no initial transform");
|
|
585
|
+
return value;
|
|
586
|
+
})();
|
|
587
|
+
const cueMotion = cueAnimation(parameters, durationFrames);
|
|
588
|
+
const cueLoop = parameters.motion.loopTarget === "cue" ? loopAnimation(parameters, durationFrames) : undefined;
|
|
589
|
+
const activationFrames = exclusiveActivationFrames(atoms, atomFrames);
|
|
590
|
+
const gapPx = `${compactNumber(parameters.layout.wordGapPx)}px`;
|
|
591
|
+
const atomWords = atoms.map(atom => atom.wordIds.map(wordId => {
|
|
592
|
+
const word = wordText.get(wordId);
|
|
593
|
+
if (!word)
|
|
594
|
+
throw new Error(`Fine Caption Atom references unknown word ${wordId}`);
|
|
595
|
+
return word;
|
|
596
|
+
}));
|
|
597
|
+
const atomSurfaces = atomWords.map(words => words.map(word => word.text));
|
|
598
|
+
const atomGaps = atomWords.map((words, index) => index > 0 && words[0].separatorBefore === " ");
|
|
599
|
+
// The Visual IR carries physical margins, so the leading edge follows the Cue's own direction.
|
|
600
|
+
const marginStart = parameters.layout.direction === "rtl" ? "margin-right" : "margin-left";
|
|
601
|
+
const spacedStyle = (spaced) => spaced ? [{ name: marginStart, value: gapPx }] : [];
|
|
602
|
+
const atomFlow = (words) => [
|
|
603
|
+
...(uniformGap(wordGaps(words)) === true ? [{ name: "column-gap", value: gapPx }] : []),
|
|
604
|
+
{ name: "display", value: "inline-flex" },
|
|
605
|
+
{ name: "min-width", value: "0" },
|
|
606
|
+
{ name: "overflow-wrap", value: "anywhere" },
|
|
607
|
+
{ name: "white-space", value: "normal" },
|
|
608
|
+
{ name: "word-break", value: parameters.layout.wrap === "grapheme" ? "break-all" : "normal" },
|
|
609
|
+
];
|
|
610
|
+
const fonts = parameters.typography.exactFonts;
|
|
611
|
+
const rowStarts = structuralRowStarts(atoms, parameters.layout.maxWordsPerLine);
|
|
612
|
+
if (parameters.layout.maxLines !== undefined) {
|
|
613
|
+
const rows = rowStarts.size;
|
|
614
|
+
if (rows > parameters.layout.maxLines) {
|
|
615
|
+
throw new Error(`Fine Caption Style ${styleId} constructs ${rows} rows for a Cue whose maximum is ${parameters.layout.maxLines}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
push({
|
|
619
|
+
id: "placement",
|
|
620
|
+
kind: "box",
|
|
621
|
+
style: [
|
|
622
|
+
{ name: "align-items", value: parameters.layout.blockAlign === "start" ? "flex-start"
|
|
623
|
+
: parameters.layout.blockAlign === "end" ? "flex-end" : "center" },
|
|
624
|
+
{ name: "display", value: "flex" },
|
|
625
|
+
{ name: "justify-content", value: parameters.layout.textAlign === "left" ? "flex-start"
|
|
626
|
+
: parameters.layout.textAlign === "right" ? "flex-end" : "center" },
|
|
627
|
+
{ name: "left", value: placementAnimation === undefined
|
|
628
|
+
? `${compactNumber(within.xPx + parameters.placement.x * within.widthPx)}px`
|
|
629
|
+
: "0px" },
|
|
630
|
+
{ name: "position", value: "absolute" },
|
|
631
|
+
{ name: "top", value: placementAnimation === undefined
|
|
632
|
+
? `${compactNumber(within.yPx + parameters.placement.y * within.heightPx)}px`
|
|
633
|
+
: "0px" },
|
|
634
|
+
...(parameters.placement.height === undefined ? [] : [
|
|
635
|
+
{ name: "height", value: `${compactNumber(parameters.placement.height * within.heightPx)}px` },
|
|
636
|
+
]),
|
|
637
|
+
...(transform === undefined ? [] : [{ name: "transform", value: transform }]),
|
|
638
|
+
{ name: "width", value: `${compactNumber(parameters.placement.width * within.widthPx)}px` },
|
|
639
|
+
],
|
|
640
|
+
...(placementAnimation === undefined ? {} : { animation: placementAnimation }),
|
|
641
|
+
});
|
|
642
|
+
push({
|
|
643
|
+
id: "cue-motion",
|
|
644
|
+
parent: "placement",
|
|
645
|
+
kind: "box",
|
|
646
|
+
style: [{ name: "display", value: "inline-grid" }, { name: "transform-origin", value: "center center" }],
|
|
647
|
+
...(cueMotion === undefined ? {} : { animation: cueMotion }),
|
|
648
|
+
});
|
|
649
|
+
push({
|
|
650
|
+
id: "cue-loop",
|
|
651
|
+
parent: "cue-motion",
|
|
652
|
+
kind: "box",
|
|
653
|
+
style: [
|
|
654
|
+
{ name: "display", value: "inline-flex" },
|
|
655
|
+
{ name: "grid-area", value: "1 / 1" },
|
|
656
|
+
{ name: "transform-origin", value: "center center" },
|
|
657
|
+
],
|
|
658
|
+
...(cueLoop === undefined ? {} : { animation: cueLoop }),
|
|
659
|
+
});
|
|
660
|
+
push({
|
|
661
|
+
id: "cue",
|
|
662
|
+
parent: "cue-loop",
|
|
663
|
+
kind: "box",
|
|
664
|
+
style: [
|
|
665
|
+
{ name: "background", value: parameters.cueBox.background },
|
|
666
|
+
...(parameters.cueBox.shadow.opacity === 0 ? [] : [{
|
|
667
|
+
name: "box-shadow",
|
|
668
|
+
value: `${compactNumber(parameters.cueBox.shadow.offsetXPx)}px ${compactNumber(parameters.cueBox.shadow.offsetYPx)}px ${compactNumber(parameters.cueBox.shadow.blurPx)}px ${compactNumber(parameters.cueBox.shadow.spreadPx)}px ${alphaColor(parameters.cueBox.shadow.color, parameters.cueBox.shadow.opacity)}`,
|
|
669
|
+
}]),
|
|
670
|
+
{ name: "box-sizing", value: "border-box" },
|
|
671
|
+
{ name: "border-color", value: parameters.cueBox.borderColor },
|
|
672
|
+
{ name: "border-radius", value: `${compactNumber(parameters.cueBox.radiusPx)}px` },
|
|
673
|
+
{ name: "border-style", value: "solid" },
|
|
674
|
+
{ name: "border-width", value: `${compactNumber(parameters.cueBox.borderWidthPx)}px` },
|
|
675
|
+
{ name: "direction", value: parameters.layout.direction },
|
|
676
|
+
{ name: "display", value: "block" },
|
|
677
|
+
{ name: "font-size", value: "0px" },
|
|
678
|
+
{ name: "line-height", value: "0" },
|
|
679
|
+
{ name: "justify-content", value: parameters.layout.textAlign === "left" ? "flex-start"
|
|
680
|
+
: parameters.layout.textAlign === "right" ? "flex-end" : "center" },
|
|
681
|
+
{ name: "max-width", value: "100%" },
|
|
682
|
+
{ name: "overflow", value: "visible" },
|
|
683
|
+
{ name: "padding", value: `${compactNumber(parameters.cueBox.paddingYPx)}px ${compactNumber(parameters.cueBox.paddingXPx)}px` },
|
|
684
|
+
{ name: "position", value: "relative" },
|
|
685
|
+
{ name: "text-align", value: parameters.layout.textAlign },
|
|
686
|
+
{ name: "width", value: parameters.layout.inlineSize === "fixed" ? "100%" : "max-content" },
|
|
687
|
+
],
|
|
688
|
+
});
|
|
689
|
+
const pushSeparator = (id, parent) => push({
|
|
690
|
+
id, parent, kind: "text", text: " ", fonts,
|
|
691
|
+
style: [
|
|
692
|
+
{ name: "display", value: "inline" },
|
|
693
|
+
{ name: "font-size", value: "0px" },
|
|
694
|
+
{ name: "letter-spacing", value: gapPx },
|
|
695
|
+
{ name: "line-height", value: "0" },
|
|
696
|
+
{ name: "white-space", value: "normal" },
|
|
697
|
+
],
|
|
698
|
+
});
|
|
699
|
+
if (parameters.activeBox.mode === "trail" && parameters.activeBox.continuity === "joined") {
|
|
700
|
+
// Fine owns this drawing program. The renderer only executes its existing HtmlVisual
|
|
701
|
+
// format; it learns nothing about captions, activation units or their layout rules.
|
|
702
|
+
push({
|
|
703
|
+
id: "joined-boxes", parent: "cue", kind: "program",
|
|
704
|
+
style: [
|
|
705
|
+
{ name: "position", value: "absolute" }, { name: "inset", value: "0" },
|
|
706
|
+
{ name: "padding", value: `${compactNumber(parameters.cueBox.paddingYPx)}px ${compactNumber(parameters.cueBox.paddingXPx)}px` },
|
|
707
|
+
],
|
|
708
|
+
program: htmlVisual({
|
|
709
|
+
html: "{{joined-layout}}" + atoms.map((_, index) => `{{joined-box-${index + 1}}}`).join(""),
|
|
710
|
+
setup: joinedBoxSetup,
|
|
711
|
+
css: ":scope { pointer-events: none; }",
|
|
712
|
+
data: {
|
|
713
|
+
wordCounts: atomWords.map(words => words.length),
|
|
714
|
+
background: parameters.activeBox.background, borderColor: parameters.activeBox.borderColor,
|
|
715
|
+
borderWidth: parameters.activeBox.borderWidthPx, radius: parameters.activeBox.radiusPx,
|
|
716
|
+
paddingX: parameters.activeBox.paddingXPx, paddingY: parameters.activeBox.paddingYPx,
|
|
717
|
+
},
|
|
718
|
+
}),
|
|
719
|
+
});
|
|
720
|
+
push({ id: "joined-layout", parent: "joined-boxes", kind: "box",
|
|
721
|
+
style: [{ name: "visibility", value: "hidden" }],
|
|
722
|
+
attributes: [{ name: "data-fine-box-layout", value: "" }],
|
|
723
|
+
});
|
|
724
|
+
// The complete stationary Cue supplies layout, including the inactive suffix. Its actual
|
|
725
|
+
// word fragments determine the background; decoration contributes no width to text flow.
|
|
726
|
+
for (const [index, words] of atomWords.entries()) {
|
|
727
|
+
if (index > 0 && rowStarts.has(index))
|
|
728
|
+
push({ id: `joined-break-${index}`, parent: "joined-layout", kind: "box",
|
|
729
|
+
style: [{ name: "display", value: "block" }, { name: "height", value: "0" }],
|
|
730
|
+
});
|
|
731
|
+
if (!rowStarts.has(index) && words[0].separatorBefore === " ")
|
|
732
|
+
pushSeparator(`joined-gap-${index}`, "joined-layout");
|
|
733
|
+
const groupId = `joined-group-${index}`;
|
|
734
|
+
push({ id: groupId, parent: "joined-layout", kind: "box", style: [...atomFlow(words), { name: "max-width", value: "100%" }] });
|
|
735
|
+
const gaps = wordGaps(words);
|
|
736
|
+
for (const [wordIndex, word] of words.entries()) {
|
|
737
|
+
push({ id: `${groupId}-word-${wordIndex}`, parent: groupId, kind: "text", text: word.text, fonts,
|
|
738
|
+
style: [...transparentGlyphStyle(parameters),
|
|
739
|
+
...(uniformGap(gaps) === undefined ? spacedStyle(gaps[wordIndex] ?? false) : [])],
|
|
740
|
+
attributes: [{ name: "data-fine-box-word", value: "" }],
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
for (const [index, atom] of atoms.entries()) {
|
|
745
|
+
const timing = atomFrames.get(atom.id);
|
|
746
|
+
const next = atoms[index + 1];
|
|
747
|
+
const nextStart = next === undefined ? durationFrames : atomFrames.get(next.id)?.start;
|
|
748
|
+
if (nextStart === undefined)
|
|
749
|
+
throw new Error("Fine Caption is missing timing for the next Atom");
|
|
750
|
+
push({ id: `joined-box-${index + 1}`, parent: "joined-boxes", kind: "box",
|
|
751
|
+
style: [{ name: "position", value: "absolute" }, { name: "inset", value: "0" }],
|
|
752
|
+
animation: activeBoxAnimation(parameters, timing.start, nextStart, durationFrames, "current"),
|
|
753
|
+
attributes: [{ name: "data-caption-active-box", value: "joined" }],
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
for (const [atomIndex, atom] of atoms.entries()) {
|
|
758
|
+
const timing = atomFrames.get(atom.id);
|
|
759
|
+
if (timing === undefined)
|
|
760
|
+
throw new Error(`Fine Caption is missing timing for Atom ${atom.id}`);
|
|
761
|
+
const activationTiming = activationFrames.get(atom.id);
|
|
762
|
+
if (activationTiming === undefined)
|
|
763
|
+
throw new Error(`Fine Caption is missing activation timing for Atom ${atom.id}`);
|
|
764
|
+
const atomId = `atom-${atomIndex + 1}`;
|
|
765
|
+
const surfaces = atomSurfaces[atomIndex] ?? [];
|
|
766
|
+
// The base glyphs and the activated copy stacked over them are laid out from this one list, so
|
|
767
|
+
// the karaoke wipe keeps sitting on the letterforms it reveals.
|
|
768
|
+
const gaps = wordGaps(atomWords[atomIndex]);
|
|
769
|
+
const wordGap = uniformGap(gaps);
|
|
770
|
+
const entryId = `${atomId}-entry`;
|
|
771
|
+
const loopId = `${atomId}-loop`;
|
|
772
|
+
const responseId = `${atomId}-response`;
|
|
773
|
+
if (atomIndex > 0 && rowStarts.has(atomIndex)) {
|
|
774
|
+
push({
|
|
775
|
+
id: `line-break-${atomIndex}`,
|
|
776
|
+
parent: "cue",
|
|
777
|
+
kind: "box",
|
|
778
|
+
style: [{ name: "display", value: "block" }, { name: "height", value: "0" }],
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
if (!rowStarts.has(atomIndex) && atomGaps[atomIndex])
|
|
782
|
+
pushSeparator(`gap-${atomIndex}`, "cue");
|
|
783
|
+
const entryAnimation = atomLifecycleAnimation(parameters, timing.start, timing.end, durationFrames);
|
|
784
|
+
const atomLoop = parameters.motion.loopTarget === "active-atom"
|
|
785
|
+
? loopAnimation(parameters, durationFrames, activationTiming) : undefined;
|
|
786
|
+
const responseAnimation = activeResponseAnimation(parameters, activationTiming.start, activationTiming.end, durationFrames);
|
|
787
|
+
push({
|
|
788
|
+
id: entryId,
|
|
789
|
+
parent: "cue",
|
|
790
|
+
kind: "box",
|
|
791
|
+
style: [
|
|
792
|
+
{ name: "display", value: "inline-flex" },
|
|
793
|
+
{ name: "min-width", value: "0" },
|
|
794
|
+
{ name: "transform-origin", value: "center center" },
|
|
795
|
+
],
|
|
796
|
+
...(entryAnimation === undefined ? {} : { animation: entryAnimation }),
|
|
797
|
+
});
|
|
798
|
+
push({
|
|
799
|
+
id: loopId,
|
|
800
|
+
parent: entryId,
|
|
801
|
+
kind: "box",
|
|
802
|
+
style: [
|
|
803
|
+
{ name: "display", value: "inline-flex" },
|
|
804
|
+
{ name: "min-width", value: "0" },
|
|
805
|
+
{ name: "transform-origin", value: "center center" },
|
|
806
|
+
],
|
|
807
|
+
...(atomLoop === undefined ? {} : { animation: atomLoop }),
|
|
808
|
+
});
|
|
809
|
+
push({
|
|
810
|
+
id: responseId,
|
|
811
|
+
parent: loopId,
|
|
812
|
+
kind: "box",
|
|
813
|
+
style: [
|
|
814
|
+
{ name: "display", value: "inline-flex" },
|
|
815
|
+
{ name: "min-width", value: "0" },
|
|
816
|
+
{ name: "transform-origin", value: "center center" },
|
|
817
|
+
],
|
|
818
|
+
...(responseAnimation === undefined ? {} : { animation: responseAnimation }),
|
|
819
|
+
});
|
|
820
|
+
push({
|
|
821
|
+
id: atomId,
|
|
822
|
+
parent: responseId,
|
|
823
|
+
kind: "box",
|
|
824
|
+
style: [
|
|
825
|
+
...atomFlow(atomWords[atomIndex]),
|
|
826
|
+
{ name: "position", value: "relative" },
|
|
827
|
+
],
|
|
828
|
+
attributes: [{ name: "data-caption-atom", value: atom.id }],
|
|
829
|
+
});
|
|
830
|
+
const useIsolatedBox = parameters.activeBox.mode !== "off"
|
|
831
|
+
&& !(parameters.activeBox.mode === "trail" && parameters.activeBox.continuity === "joined");
|
|
832
|
+
if (useIsolatedBox) {
|
|
833
|
+
push({
|
|
834
|
+
id: `${atomId}-box`,
|
|
835
|
+
parent: atomId,
|
|
836
|
+
kind: "box",
|
|
837
|
+
style: [
|
|
838
|
+
{ name: "background", value: parameters.activeBox.background },
|
|
839
|
+
{ name: "border-color", value: parameters.activeBox.borderColor },
|
|
840
|
+
{ name: "border-radius", value: `${compactNumber(parameters.activeBox.radiusPx)}px` },
|
|
841
|
+
{ name: "border-style", value: "solid" },
|
|
842
|
+
{ name: "border-width", value: `${compactNumber(parameters.activeBox.borderWidthPx)}px` },
|
|
843
|
+
{ name: "bottom", value: `${compactNumber(-parameters.activeBox.paddingYPx)}px` },
|
|
844
|
+
{ name: "left", value: `${compactNumber(-parameters.activeBox.paddingXPx)}px` },
|
|
845
|
+
{ name: "position", value: "absolute" },
|
|
846
|
+
{ name: "right", value: `${compactNumber(-parameters.activeBox.paddingXPx)}px` },
|
|
847
|
+
{ name: "top", value: `${compactNumber(-parameters.activeBox.paddingYPx)}px` },
|
|
848
|
+
{ name: "transform-origin", value: "center center" },
|
|
849
|
+
],
|
|
850
|
+
animation: activeBoxAnimation(parameters, parameters.activeBox.mode === "current" ? activationTiming.start : timing.start, parameters.activeBox.mode === "current" ? activationTiming.end : timing.end, durationFrames),
|
|
851
|
+
attributes: [{ name: "data-caption-active-box", value: "isolated" }],
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
const graphemes = parameters.motion.atomReveal === "typewriter"
|
|
855
|
+
? surfaces.map((text) => [...new Intl.Segmenter("und", { granularity: "grapheme" }).segment(text)].map((part) => part.segment))
|
|
856
|
+
: [];
|
|
857
|
+
const graphemeCount = graphemes.reduce((sum, word) => sum + word.length, 0);
|
|
858
|
+
const pushText = (element, wordIndex) => {
|
|
859
|
+
if (parameters.motion.atomReveal !== "typewriter") {
|
|
860
|
+
push(element);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
// Reveal complete glyphs, not fractions of a Word's width. Variable-width Latin letters,
|
|
864
|
+
// Han characters and combining sequences all retain their actual layout widths.
|
|
865
|
+
const isMargin = (name) => name.startsWith("margin-");
|
|
866
|
+
push({
|
|
867
|
+
id: element.id,
|
|
868
|
+
...(element.parent === undefined ? {} : { parent: element.parent }),
|
|
869
|
+
kind: "box",
|
|
870
|
+
style: [
|
|
871
|
+
{ name: "display", value: "inline" },
|
|
872
|
+
{ name: "min-width", value: "0" },
|
|
873
|
+
...element.style.filter(({ name }) => isMargin(name) || name === "opacity"),
|
|
874
|
+
],
|
|
875
|
+
...(element.attributes === undefined ? {} : { attributes: element.attributes }),
|
|
876
|
+
});
|
|
877
|
+
const preceding = graphemes.slice(0, wordIndex).reduce((sum, word) => sum + word.length, 0);
|
|
878
|
+
const { attributes: _attributes, ...glyph } = element;
|
|
879
|
+
for (const [index, text] of (graphemes[wordIndex] ?? []).entries()) {
|
|
880
|
+
const revealFrame = timing.start + Math.floor((timing.end - timing.start) * (preceding + index) / Math.max(1, graphemeCount));
|
|
881
|
+
push({
|
|
882
|
+
...glyph,
|
|
883
|
+
id: `${element.id}-grapheme-${index + 1}`,
|
|
884
|
+
parent: element.id,
|
|
885
|
+
text,
|
|
886
|
+
style: [
|
|
887
|
+
...element.style.filter(({ name }) => !isMargin(name) && name !== "opacity"),
|
|
888
|
+
{ name: "display", value: "inline" },
|
|
889
|
+
],
|
|
890
|
+
animation: activationStepAnimation("trail", revealFrame, durationFrames, durationFrames),
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
for (const [wordIndex, wordId] of atom.wordIds.entries()) {
|
|
895
|
+
const text = wordText.get(wordId)?.text;
|
|
896
|
+
if (text === undefined)
|
|
897
|
+
throw new Error(`Fine Caption Atom references unknown word ${wordId}`);
|
|
898
|
+
pushText({
|
|
899
|
+
id: `${atomId}-base-${wordIndex + 1}`,
|
|
900
|
+
parent: atomId,
|
|
901
|
+
kind: "text",
|
|
902
|
+
text,
|
|
903
|
+
style: [
|
|
904
|
+
...glyphStyle(parameters, parameters.basePaint, parameters.underline),
|
|
905
|
+
...(wordGap === undefined ? spacedStyle(gaps[wordIndex] ?? false) : []),
|
|
906
|
+
],
|
|
907
|
+
...glyphPaintFields(parameters.basePaint),
|
|
908
|
+
fonts,
|
|
909
|
+
}, wordIndex);
|
|
910
|
+
}
|
|
911
|
+
const addActivatedTextLayer = (suffix, mode, kind) => {
|
|
912
|
+
const activeId = `${atomId}-${suffix}`;
|
|
913
|
+
const activeTiming = mode === "current" ? activationTiming : timing;
|
|
914
|
+
push({
|
|
915
|
+
id: activeId,
|
|
916
|
+
parent: atomId,
|
|
917
|
+
kind: "box",
|
|
918
|
+
style: [
|
|
919
|
+
...(wordGap === true ? [{ name: "column-gap", value: gapPx }] : []),
|
|
920
|
+
{ name: "display", value: "inline-flex" },
|
|
921
|
+
{ name: "inset", value: "0" },
|
|
922
|
+
// The wipe's own `clip-path` is what reveals this layer a word at a time, and it clips to the
|
|
923
|
+
// same border box, so an `overflow` here adds nothing — except at full reveal, where the
|
|
924
|
+
// clip has opened and this was still cutting the outline off at the glyph advance.
|
|
925
|
+
{ name: "position", value: "absolute" },
|
|
926
|
+
],
|
|
927
|
+
animation: kind === "glyph" && parameters.karaoke.transition === "wipe"
|
|
928
|
+
? karaokeWipeAnimation(parameters, mode, activeTiming.start, activeTiming.end, durationFrames)
|
|
929
|
+
: activationStepAnimation(mode, activeTiming.start, activeTiming.end, durationFrames),
|
|
930
|
+
attributes: [{ name: kind === "glyph" ? "data-caption-karaoke" : "data-caption-active-underline", value: mode }],
|
|
931
|
+
});
|
|
932
|
+
for (const [wordIndex, wordId] of atom.wordIds.entries()) {
|
|
933
|
+
const text = wordText.get(wordId).text;
|
|
934
|
+
pushText({
|
|
935
|
+
id: `${activeId}-${wordIndex + 1}`,
|
|
936
|
+
parent: activeId,
|
|
937
|
+
kind: "text",
|
|
938
|
+
text,
|
|
939
|
+
style: [
|
|
940
|
+
...(kind === "glyph"
|
|
941
|
+
? glyphStyle(parameters, parameters.activePaint)
|
|
942
|
+
: transparentGlyphStyle(parameters, parameters.activeUnderline)),
|
|
943
|
+
...(wordGap === undefined ? spacedStyle(gaps[wordIndex] ?? false) : []),
|
|
944
|
+
],
|
|
945
|
+
// The underline layer paints no glyph at all, so it declares no glyph Paint either.
|
|
946
|
+
...(kind === "glyph" ? glyphPaintFields(parameters.activePaint) : {}),
|
|
947
|
+
fonts,
|
|
948
|
+
attributes: [{ name: kind === "glyph" ? "data-caption-active-word" : "data-caption-underlined-word", value: wordId }],
|
|
949
|
+
}, wordIndex);
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
if (parameters.karaoke.mode !== "off")
|
|
953
|
+
addActivatedTextLayer("active", parameters.karaoke.mode, "glyph");
|
|
954
|
+
if (parameters.activeUnderline.mode !== "off") {
|
|
955
|
+
addActivatedTextLayer("underline", parameters.activeUnderline.mode, "underline");
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return elements;
|
|
959
|
+
}
|
|
960
|
+
export function renderFineCaption(schedule, program, document, timeline, within, regions) {
|
|
961
|
+
assertFineCaptionSchedule(schedule);
|
|
962
|
+
assertCaptionProgramForDocument(program, document);
|
|
963
|
+
if (schedule.documentId !== document.id)
|
|
964
|
+
throw new Error("Fine Caption received another CaptionDocument");
|
|
965
|
+
assertTimelineIdentity(timeline);
|
|
966
|
+
assertSpatialFrame(within);
|
|
967
|
+
if (schedule.timelineId !== timeline.id) {
|
|
968
|
+
throw new Error("Fine Caption inputs belong to different Timelines");
|
|
969
|
+
}
|
|
970
|
+
const styles = new Map(program.styles.map((style) => [style.id, style]));
|
|
971
|
+
const wordText = new Map(document.words.map((word) => [word.id, word]));
|
|
972
|
+
const atomById = new Map(document.units.map((atom) => [atom.id, atom]));
|
|
973
|
+
const cueById = new Map(document.cues.map((cue) => [cue.id, cue]));
|
|
974
|
+
for (const style of styles.values()) {
|
|
975
|
+
if (style.rendering === null)
|
|
976
|
+
continue;
|
|
977
|
+
if (style.rendering.family !== FINE_CAPTION_FAMILY) {
|
|
978
|
+
throw new Error(`Fine Caption cannot render Style family ${style.rendering.family}`);
|
|
979
|
+
}
|
|
980
|
+
assertFineCaptionParameters(style.rendering.parameters);
|
|
981
|
+
}
|
|
982
|
+
const totalFrames = timelineFrameCount(timeline);
|
|
983
|
+
const regionSeriesById = regions === undefined ? undefined : (() => {
|
|
984
|
+
assertRegionEvidence(regions);
|
|
985
|
+
const regionFrameCount = regions.series[0].frames.length;
|
|
986
|
+
if (regions.timelineId !== timeline.id || regionFrameCount !== totalFrames) {
|
|
987
|
+
throw new Error(`Fine Caption regions do not cover Timeline ${timeline.id}'s ${totalFrames} Frames`);
|
|
988
|
+
}
|
|
989
|
+
return new Map(regions.series.map((series) => [series.id, series]));
|
|
990
|
+
})();
|
|
991
|
+
const presents = schedule.cues.flatMap((cue, order) => {
|
|
992
|
+
const authoredCue = cueById.get(cue.cueId);
|
|
993
|
+
if (authoredCue === undefined)
|
|
994
|
+
throw new Error(`Fine Caption Schedule references unknown authored Cue ${cue.cueId}`);
|
|
995
|
+
const atoms = cue.units.map((timing) => atomById.get(timing.unitId));
|
|
996
|
+
if (atoms.some((atom) => atom === undefined))
|
|
997
|
+
throw new Error(`Fine Caption Cue ${cue.id} references unknown Atom`);
|
|
998
|
+
const resolvedAtoms = atoms.map((atom) => atom);
|
|
999
|
+
const style = styles.get(cue.styleId);
|
|
1000
|
+
if (style === undefined)
|
|
1001
|
+
throw new Error(`Fine Caption Cue ${cue.id} references unknown Style ${cue.styleId}`);
|
|
1002
|
+
if (style.rendering === null)
|
|
1003
|
+
return [];
|
|
1004
|
+
const parameters = style.rendering.parameters;
|
|
1005
|
+
const startFrame = Math.max(0, cue.visibleStartFrame);
|
|
1006
|
+
const measuredEnd = Math.min(totalFrames, cue.visibleEndFrameExclusive);
|
|
1007
|
+
const endFrameExclusive = Math.min(totalFrames, Math.max(startFrame + 1, measuredEnd));
|
|
1008
|
+
if (startFrame >= totalFrames || endFrameExclusive <= startFrame)
|
|
1009
|
+
return [];
|
|
1010
|
+
const durationFrames = endFrameExclusive - startFrame;
|
|
1011
|
+
const trackedPlacement = regionSeriesById === undefined ? undefined : (() => {
|
|
1012
|
+
const role = authoredCue.role;
|
|
1013
|
+
if (role === undefined)
|
|
1014
|
+
throw new Error(`Fine Caption Cue ${cue.id} must have one Script Role to follow regions`);
|
|
1015
|
+
const series = regionSeriesById.get(role);
|
|
1016
|
+
if (series === undefined)
|
|
1017
|
+
return undefined;
|
|
1018
|
+
return {
|
|
1019
|
+
frames: series.frames.slice(startFrame, endFrameExclusive),
|
|
1020
|
+
};
|
|
1021
|
+
})();
|
|
1022
|
+
const atomFrames = new Map(cue.units.map((atom) => [atom.unitId, {
|
|
1023
|
+
start: clamp(atom.startFrame - startFrame, 0, durationFrames),
|
|
1024
|
+
end: clamp(Math.max(atom.endFrameExclusive - startFrame, atom.startFrame - startFrame + 1), 0, durationFrames),
|
|
1025
|
+
}]));
|
|
1026
|
+
return [{
|
|
1027
|
+
id: cue.id,
|
|
1028
|
+
order,
|
|
1029
|
+
z: parameters.stackingOrder,
|
|
1030
|
+
span: { startFrame, endFrameExclusive },
|
|
1031
|
+
visibility: cue.visibility,
|
|
1032
|
+
elements: cueElements(resolvedAtoms, atomFrames, parameters, wordText, durationFrames, cue.styleId, within, trackedPlacement),
|
|
1033
|
+
}];
|
|
1034
|
+
});
|
|
1035
|
+
const track = sealVisualTrack({
|
|
1036
|
+
timelineId: timeline.id,
|
|
1037
|
+
visualIr: "hypit.visual-ir@1",
|
|
1038
|
+
id: program.id,
|
|
1039
|
+
presents,
|
|
1040
|
+
});
|
|
1041
|
+
assertVisualTrackIdentity(track, timeline);
|
|
1042
|
+
return track;
|
|
1043
|
+
}
|