@@ -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