Skip to content

Commit 437eb7b

Browse files
authored
Merge pull request #124 from shotstack/derk/eng-653-studio-shared-resolver
fix: shared rich-caption font resolution + preview/export render-parity fixes
2 parents 172d84a + 73465e9 commit 437eb7b

27 files changed

Lines changed: 620 additions & 158 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
},
101101
"dependencies": {
102102
"@shotstack/schemas": "1.11.0",
103-
"@shotstack/shotstack-canvas": "^2.9.0",
103+
"@shotstack/shotstack-canvas": "^2.10.0",
104104
"howler": "^2.2.4",
105105
"mediabunny": "^1.11.2",
106106
"opentype.js": "^1.3.4",

src/components/canvas/players/html5-player.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Edit } from "@core/edit-session";
33
import { EditEvent } from "@core/events/edit-events";
44
import { type Size } from "@layouts/geometry";
55
import type { ResolvedClip } from "@schemas";
6+
import { nextFrame } from "@shared/utils";
67
import { Html5AssetSchema, composeHtml5IframeSrcdoc, computeHtml5FrameCount, type Html5Asset } from "@shotstack/shotstack-canvas";
78
import * as pixi from "pixi.js";
89

@@ -19,13 +20,6 @@ type HarnessWindow = Window & {
1920
const SEEK_KEY = "__shotstackSeek" as const;
2021
const CAPTURE_CONCURRENCY = 4;
2122

22-
function yieldFrame(): Promise<void> {
23-
return new Promise<void>(resolve => {
24-
if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => resolve());
25-
else setTimeout(() => resolve(), 0);
26-
});
27-
}
28-
2923
function forceLayout(el: HTMLElement): void {
3024
el.getBoundingClientRect();
3125
}
@@ -313,7 +307,7 @@ export class Html5Player extends Player {
313307
} catch {
314308
/* older browsers — proceed */
315309
}
316-
await yieldFrame();
310+
await nextFrame();
317311
if (stale()) return null;
318312

319313
const { frameCount } = computeHtml5FrameCount({
@@ -330,7 +324,7 @@ export class Html5Player extends Player {
330324

331325
const blobs: Blob[] = [];
332326
for (let i = 0; i < frameCount; i += CAPTURE_CONCURRENCY) {
333-
await yieldFrame();
327+
await nextFrame();
334328
if (stale()) return null;
335329
const batch: Promise<Uint8Array>[] = [];
336330
for (let j = i; j < Math.min(i + CAPTURE_CONCURRENCY, frameCount); j += 1) {

src/components/canvas/players/player.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ComposedKeyframeBuilder } from "@animations/composed-keyframe-builder";
22
import { EffectPresetBuilder } from "@animations/effect-preset-builder";
33
import { KeyframeBuilder } from "@animations/keyframe-builder";
44
import { TransitionPresetBuilder } from "@animations/transition-preset-builder";
5+
import { WipeFilter } from "@animations/wipe-filter";
56
import { type Edit } from "@core/edit-session";
67
import { InternalEvent } from "@core/events/edit-events";
78
import { calculateContainerScale, calculateFitScale, calculateSpriteTransform, type FitMode } from "@core/layout/fit-system";
@@ -87,6 +88,11 @@ export abstract class Player extends Entity {
8788
private skewXKeyframeBuilder?: ComposedKeyframeBuilder;
8889
private skewYKeyframeBuilder?: ComposedKeyframeBuilder;
8990
private maskXKeyframeBuilder?: KeyframeBuilder;
91+
private wipeBrightnessBuilder?: KeyframeBuilder;
92+
private wipeInFromRight: boolean = false;
93+
private wipeOutFromRight: boolean = false;
94+
private wipeOutStart: number = Number.POSITIVE_INFINITY;
95+
private wipeFilter: WipeFilter | null = null;
9096

9197
private wipeMask: pixi.Graphics | null;
9298
protected lumaWrapper: pixi.Container;
@@ -208,11 +214,20 @@ export abstract class Player extends Entity {
208214
this.opacityKeyframeBuilder.addLayer(transitionSet.out.opacityKeyframes);
209215
this.rotationKeyframeBuilder.addLayer(transitionSet.out.rotationKeyframes);
210216

211-
// Mask keyframes (wipe/reveal effects)
217+
// Mask keyframes (reveal effect)
212218
const maskXKeyframes: Keyframe[] = [...transitionSet.in.maskXKeyframes, ...transitionSet.out.maskXKeyframes];
213219
if (maskXKeyframes.length) {
214220
this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes, length);
215221
}
222+
223+
// Wipe brightness sweep (wipeLeft/wipeRight) — driven through WipeFilter, not a mask.
224+
const brightnessKeyframes: Keyframe[] = [...transitionSet.in.brightnessKeyframes, ...transitionSet.out.brightnessKeyframes];
225+
if (brightnessKeyframes.length) {
226+
this.wipeBrightnessBuilder = new KeyframeBuilder(brightnessKeyframes, length);
227+
this.wipeInFromRight = transitionSet.in.wipeFromRight;
228+
this.wipeOutFromRight = transitionSet.out.wipeFromRight;
229+
this.wipeOutStart = transitionSet.out.brightnessKeyframes[0]?.start ?? Number.POSITIVE_INFINITY;
230+
}
216231
}
217232

218233
public override async load(): Promise<void> {
@@ -234,6 +249,14 @@ export abstract class Player extends Entity {
234249
}
235250

236251
public override update(_: number, __: number): void {
252+
this.applyPlayheadState();
253+
}
254+
255+
/**
256+
* Apply all playhead-time-dependent container state (visibility, transform, opacity, wipe mask)
257+
* for the current playback time. Called by the per-frame tick and directly by static capture.
258+
*/
259+
public applyPlayheadState(): void {
237260
this.getContainer().visible = this.isActive();
238261
this.getContainer().zIndex = 100000 - this.layer * 100;
239262
if (!this.isActive()) {
@@ -262,6 +285,7 @@ export abstract class Player extends Entity {
262285

263286
// Update wipe/reveal mask animation
264287
this.updateWipeMask();
288+
this.updateWipeFilter();
265289

266290
if (this.shouldDiscardFrame()) {
267291
this.contentContainer.alpha = 0;
@@ -294,9 +318,32 @@ export abstract class Player extends Entity {
294318
this.wipeMask.fill(0xffffff);
295319
}
296320

321+
private updateWipeFilter(): void {
322+
if (!this.wipeBrightnessBuilder) {
323+
this.removeWipeFilter();
324+
return;
325+
}
326+
if (!this.wipeFilter) {
327+
this.wipeFilter = new WipeFilter();
328+
this.contentContainer.filters = [this.wipeFilter];
329+
}
330+
const time = this.getPlaybackTime();
331+
this.wipeFilter.brightness = this.wipeBrightnessBuilder.getValue(time);
332+
this.wipeFilter.revealFromRight = time >= this.wipeOutStart ? this.wipeOutFromRight : this.wipeInFromRight;
333+
}
334+
335+
private removeWipeFilter(): void {
336+
if (!this.wipeFilter) return;
337+
this.contentContainer.filters = [];
338+
this.wipeFilter.destroy();
339+
this.wipeFilter = null;
340+
}
341+
297342
public override dispose(): void {
298343
this.wipeMask?.destroy();
299344
this.wipeMask = null;
345+
this.wipeFilter?.destroy();
346+
this.wipeFilter = null;
300347

301348
this.contentContainer?.destroy();
302349
this.lumaWrapper?.destroy();

src/components/canvas/players/rich-caption-player.ts

Lines changed: 75 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
createDefaultGeneratorConfig,
1313
createWebPainter,
1414
buildCaptionLayoutConfig,
15+
resolveCaptionFonts,
1516
parseSubtitleToWords,
1617
CanvasRichCaptionAssetSchema,
1718
type CanvasRichCaptionAsset,
@@ -34,6 +35,7 @@ export class RichCaptionPlayer extends Player {
3435

3536
private canvas: HTMLCanvasElement | null = null;
3637
private painter: ReturnType<typeof createWebPainter> | null = null;
38+
private currentRender: Promise<void> | null = null; // serialises the async paint so captures await a finished frame
3739
private texture: pixi.Texture | null = null;
3840
private sprite: pixi.Sprite | null = null;
3941

@@ -139,6 +141,16 @@ export class RichCaptionPlayer extends Player {
139141
this.renderFrameSync(currentTimeMs);
140142
}
141143

144+
/**
145+
* Render the exact playhead frame to completion for an off-playback capture. The web painter is
146+
* async, so this awaits {@link renderFrame} (which awaits the paint) before captureFrame extracts —
147+
* otherwise the snapshot is a half-drawn caption. Asset loading is awaited by the caller.
148+
* @internal
149+
*/
150+
public override async prepareStaticRender(): Promise<void> {
151+
if (this.loadComplete) await this.renderFrame(this.getPlaybackTime() * 1000);
152+
}
153+
142154
public override async reloadAsset(): Promise<void> {
143155
const asset = this.clipConfiguration.asset as RichCaptionAsset;
144156

@@ -218,11 +230,6 @@ export class RichCaptionPlayer extends Player {
218230

219231
const { width, height } = this.getSize();
220232
const layoutConfig = buildCaptionLayoutConfig(this.validatedAsset, width, height);
221-
const letterSpacing = this.validatedAsset?.style?.letterSpacing;
222-
const canvasTextMeasurer = this.createCanvasTextMeasurer(letterSpacing);
223-
if (canvasTextMeasurer) {
224-
layoutConfig.measureTextWidth = canvasTextMeasurer;
225-
}
226233
this.captionLayout = await this.layoutEngine.layoutCaption(this.words, layoutConfig);
227234

228235
this.generatorConfig = createDefaultGeneratorConfig(width, height, 1);
@@ -253,12 +260,6 @@ export class RichCaptionPlayer extends Player {
253260
const { width, height } = this.getSize();
254261
const layoutConfig = buildCaptionLayoutConfig(this.validatedAsset, width, height);
255262

256-
const letterSpacing = this.validatedAsset?.style?.letterSpacing;
257-
const canvasTextMeasurer = this.createCanvasTextMeasurer(letterSpacing);
258-
if (canvasTextMeasurer) {
259-
layoutConfig.measureTextWidth = canvasTextMeasurer;
260-
}
261-
262263
this.captionLayout = await this.layoutEngine.layoutCaption(words, layoutConfig);
263264

264265
this.generatorConfig = createDefaultGeneratorConfig(width, height, 1);
@@ -273,7 +274,31 @@ export class RichCaptionPlayer extends Player {
273274
this.loadComplete = true;
274275
}
275276

277+
/**
278+
* Render the caption at `timeMs`, serialised so the shared canvas isn't cleared mid-paint.
279+
* Awaited by {@link prepareStaticRender} for off-playback captures.
280+
*/
281+
private async renderFrame(timeMs: number): Promise<void> {
282+
while (this.currentRender) {
283+
await this.currentRender;
284+
}
285+
this.currentRender = this.paintFrame(timeMs).finally(() => {
286+
this.currentRender = null;
287+
});
288+
await this.currentRender;
289+
}
290+
291+
/** Fire-and-forget render for the live tick and lifecycle hooks; the texture updates when the paint settles. */
276292
private renderFrameSync(timeMs: number): void {
293+
this.renderFrame(timeMs).catch(() => {});
294+
}
295+
296+
/**
297+
* Paint the caption frame to completion. The web painter is async (glyph fills resolve
298+
* asynchronously), so the Pixi texture is only updated once the paint has finished — otherwise a
299+
* snapshot captures a half-drawn canvas (a single glyph).
300+
*/
301+
private async paintFrame(timeMs: number): Promise<void> {
277302
if (!this.layoutEngine || !this.captionLayout || !this.canvas || !this.painter || !this.validatedAsset || !this.generatorConfig) {
278303
return;
279304
}
@@ -289,7 +314,7 @@ export class RichCaptionPlayer extends Player {
289314
const ctx = this.canvas.getContext("2d");
290315
if (ctx) ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
291316

292-
this.painter.render(ops);
317+
await this.painter.render(ops);
293318

294319
if (!this.texture) {
295320
this.texture = pixi.Texture.from(this.canvas);
@@ -323,20 +348,46 @@ export class RichCaptionPlayer extends Player {
323348
}
324349
}
325350

351+
// Shared font resolution used by both registration and the render payload, so the registered
352+
// face and the rendered family are always the same. Delegates to the canvas resolver.
353+
private resolveFontsForAsset(asset: RichCaptionAsset) {
354+
const family = asset.font?.family ?? "Roboto";
355+
const weight = asset.font?.weight ? parseInt(String(asset.font.weight), 10) || 400 : 400;
356+
const fontNameMap = new Map<string, string>();
357+
for (const [src, meta] of this.edit.getFontMetadata()) fontNameMap.set(src, meta.baseFamilyName);
358+
const activeFamily = asset.active?.font?.family;
359+
const activeWeight = asset.active?.font?.weight;
360+
return resolveCaptionFonts({
361+
family,
362+
weight,
363+
timelineFonts: this.edit.getTimelineFonts(),
364+
fontNameMap,
365+
...(activeFamily ? { activeFamily } : {}),
366+
...(activeWeight != null ? { activeWeight: activeWeight as string | number } : {})
367+
});
368+
}
369+
326370
private async registerFonts(asset: RichCaptionAsset): Promise<void> {
327371
if (!this.fontRegistry) return;
328372

329373
const family = asset.font?.family ?? "Roboto";
330374
const assetWeight = asset.font?.weight ? parseInt(String(asset.font.weight), 10) || 400 : 400;
375+
376+
// Registration and the render payload (buildCanvasPayload) share this one resolution, so the
377+
// render looks up exactly the face that was registered.
378+
const resolution = this.resolveFontsForAsset(asset);
379+
380+
if (resolution.matched) {
381+
for (const font of resolution.fonts) {
382+
if (font.src) await this.registerFontFromUrl(font.src, font.family, parseInt(font.weight, 10) || 400);
383+
}
384+
return;
385+
}
386+
331387
const resolved = this.resolveFontWithWeight(family, assetWeight);
332388
if (resolved) {
333389
await this.registerFontFromUrl(resolved.url, resolved.baseFontFamily, resolved.fontWeight);
334390
}
335-
336-
const customFonts = this.buildCustomFontsFromTimeline(asset);
337-
for (const customFont of customFonts) {
338-
await this.registerFontFromUrl(customFont.src, customFont.family, parseInt(customFont.weight, 10) || 400);
339-
}
340391
}
341392

342393
private async registerFontFromUrl(url: string, family: string, weight: number): Promise<boolean> {
@@ -465,8 +516,12 @@ export class RichCaptionPlayer extends Player {
465516

466517
private buildCanvasPayload(asset: RichCaptionAsset, words: WordTiming[]): Record<string, unknown> {
467518
const { width, height } = this.getSize();
468-
const customFonts = this.buildCustomFontsFromTimeline(asset);
469-
const resolvedFamily = getFontDisplayName(asset.font?.family ?? "Roboto");
519+
// Use the same resolution as registration, so the rendered family matches the registered face.
520+
const resolution = this.resolveFontsForAsset(asset);
521+
const resolvedFamily = resolution.matched ? resolution.resolvedFamily : getFontDisplayName(asset.font?.family ?? "Roboto");
522+
const customFonts = resolution.matched
523+
? resolution.fonts.filter(f => f.src).map(f => ({ src: f.src as string, family: f.family, weight: f.weight }))
524+
: this.buildCustomFontsFromTimeline(asset);
470525

471526
const payload: Record<string, unknown> = {
472527
type: asset.type,
@@ -484,7 +539,7 @@ export class RichCaptionPlayer extends Player {
484539
border: asset.border,
485540
padding: asset.padding,
486541
style: asset.style,
487-
wordAnimation: asset.animation,
542+
animation: asset.animation,
488543
align: asset.align,
489544
pauseThreshold: this.resolvedPauseThreshold
490545
};
@@ -502,25 +557,6 @@ export class RichCaptionPlayer extends Player {
502557
return payload;
503558
}
504559

505-
private createCanvasTextMeasurer(letterSpacing?: number): ((text: string, font: string) => number) | undefined {
506-
try {
507-
const measureCanvas = document.createElement("canvas");
508-
const ctx = measureCanvas.getContext("2d");
509-
if (!ctx) return undefined;
510-
511-
if (letterSpacing) {
512-
(ctx as unknown as Record<string, unknown>)["letterSpacing"] = `${letterSpacing}px`;
513-
}
514-
515-
return (text: string, font: string): number => {
516-
ctx.font = font;
517-
return ctx.measureText(text).width;
518-
};
519-
} catch {
520-
return undefined;
521-
}
522-
}
523-
524560
private createFallbackGraphic(message: string): void {
525561
const { width, height } = this.getSize();
526562

@@ -652,11 +688,6 @@ export class RichCaptionPlayer extends Player {
652688
if (!this.layoutEngine) return;
653689

654690
const layoutConfig = buildCaptionLayoutConfig(this.validatedAsset, width, height);
655-
const letterSpacing = this.validatedAsset?.style?.letterSpacing;
656-
const canvasTextMeasurer = this.createCanvasTextMeasurer(letterSpacing);
657-
if (canvasTextMeasurer) {
658-
layoutConfig.measureTextWidth = canvasTextMeasurer;
659-
}
660691

661692
this.pendingLayoutId += 1;
662693
const layoutId = this.pendingLayoutId;

0 commit comments

Comments
 (0)