diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index 0b3ed0f..9072614 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -16,7 +16,20 @@ type TextEngine = Awaited>; export class RichTextPlayer extends Player { private static readonly PREVIEW_FPS = 60; + /** CSS font-weight string → numeric value. Extends WEIGHT_MODIFIERS (@core/fonts/font-config.ts) with CSS aliases. */ + private static readonly NAMED_WEIGHTS: Record = { + thin: 100, hairline: 100, + extralight: 200, ultralight: 200, + light: 300, + normal: 400, regular: 400, + medium: 500, + semibold: 600, demibold: 600, + bold: 700, + extrabold: 800, ultrabold: 800, + black: 900, heavy: 900, + }; private static readonly fontCapabilityCache = new Map>(); + private static readonly fontBytesCache = new Map>(); private textEngine: TextEngine | null = null; private renderer: ReturnType | null = null; private canvas: HTMLCanvasElement | null = null; @@ -36,6 +49,22 @@ export class RichTextPlayer extends Player { return withoutHash.split("?", 1)[0]; } + private static fetchFontBytes(url: string): Promise { + const cacheKey = RichTextPlayer.getFontSourceCacheKey(url); + const cached = RichTextPlayer.fontBytesCache.get(cacheKey); + if (cached) return cached; + + const fetchPromise = fetch(url).then(res => { + if (!res.ok) throw new Error(`Failed to fetch font: ${res.status}`); + return res.arrayBuffer(); + }).catch(err => { + RichTextPlayer.fontBytesCache.delete(cacheKey); + throw err; + }); + RichTextPlayer.fontBytesCache.set(cacheKey, fetchPromise); + return fetchPromise; + } + constructor(edit: Edit, clipConfiguration: ResolvedClip) { // Remove fit property for rich-text assets // This aligns with @shotstack/schemas v1.5.6 which filters fit at track validation @@ -45,13 +74,14 @@ export class RichTextPlayer extends Player { private resolveFontWeight(richTextAsset: RichTextAsset, fallbackWeight: number): number { const explicitWeight = richTextAsset.font?.weight; + if (typeof explicitWeight === "number") return explicitWeight; if (typeof explicitWeight === "string") { - return parseInt(explicitWeight, 10) || fallbackWeight; - } - if (typeof explicitWeight === "number") { - return explicitWeight; + const named = RichTextPlayer.NAMED_WEIGHTS[explicitWeight.toLowerCase().trim()]; + if (named !== undefined) return named; + const parsed = parseInt(explicitWeight, 10); + if (!Number.isNaN(parsed)) return parsed; + console.warn(`Unrecognized font weight "${explicitWeight}", defaulting to ${fallbackWeight}`); } - return fallbackWeight; } @@ -108,7 +138,7 @@ export class RichTextPlayer extends Player { // Determine the font family for the canvas payload: // Use matched custom font name, or built-in font, or fall back to Roboto - const hasFontMatch = customFonts || (requestedFamily && resolveFontPath(requestedFamily)); + const hasFontMatch = customFonts || (requestedFamily && resolveFontPath(requestedFamily, fontWeight)); const resolvedFamily = hasFontMatch ? baseFontFamily || requestedFamily : undefined; return { @@ -136,7 +166,8 @@ export class RichTextPlayer extends Player { try { const fontDesc = { family, weight: weight.toString() }; if (source.type === "url") { - await this.textEngine!.registerFontFromUrl(source.path, fontDesc); + const bytes = await RichTextPlayer.fetchFontBytes(source.path); + await this.textEngine!.registerFontFromFile(new Blob([bytes]), fontDesc); } else { await this.textEngine!.registerFontFromFile(source.path, fontDesc); } @@ -152,11 +183,7 @@ export class RichTextPlayer extends Player { private createFontCapabilityCheckPromise(fontUrl: string): Promise { return (async (): Promise => { try { - const response = await fetch(fontUrl); - if (!response.ok) { - throw new Error(`Failed to fetch font: ${response.status}`); - } - const buffer = await response.arrayBuffer(); + const buffer = await RichTextPlayer.fetchFontBytes(fontUrl); const font = opentype.parse(buffer); // Check for fvar table (variable font) with weight axis @@ -193,14 +220,13 @@ export class RichTextPlayer extends Player { return this.fontSupportsBold; } - private resolveFont(family: string): { url: string; baseFontFamily: string; fontWeight: number } | null { - const { baseFontFamily, fontWeight } = parseFontFamily(family); + private resolveFont(family: string, weight: number): { url: string; baseFontFamily: string; fontWeight: number } | null { + const { baseFontFamily } = parseFontFamily(family); // Check stored font metadata first (for template fonts with UUID-based URLs) - // Uses normalized base family + weight to match the correct font file - const metadataUrl = this.edit.getFontUrlByFamilyAndWeight(baseFontFamily, fontWeight); + const metadataUrl = this.edit.getFontUrlByFamilyAndWeight(baseFontFamily, weight); if (metadataUrl) { - return { url: metadataUrl, baseFontFamily, fontWeight }; + return { url: metadataUrl, baseFontFamily, fontWeight: weight }; } // Check timeline fonts by filename matching (legacy fallback) @@ -213,13 +239,13 @@ export class RichTextPlayer extends Player { }); if (matchingFont) { - return { url: matchingFont.src, baseFontFamily, fontWeight }; + return { url: matchingFont.src, baseFontFamily, fontWeight: weight }; } - // Fall back to built-in fonts from FONT_PATHS - const builtInPath = resolveFontPath(family); - if (builtInPath) { - return { url: builtInPath, baseFontFamily, fontWeight }; + // Fall back to built-in/Google fonts — single function, single priority chain + const resolvedPath = resolveFontPath(family, weight); + if (resolvedPath) { + return { url: resolvedPath, baseFontFamily, fontWeight: weight }; } return null; @@ -261,10 +287,10 @@ export class RichTextPlayer extends Player { const family = richTextAsset.font?.family; if (!family) return null; - const resolved = this.resolveFont(family); + const fontWeight = this.resolveFontWeight(richTextAsset, parseFontFamily(family).fontWeight); + const resolved = this.resolveFont(family, fontWeight); if (!resolved) return null; - const fontWeight = this.resolveFontWeight(richTextAsset, resolved.fontWeight); await this.registerFont(resolved.baseFontFamily, fontWeight, { type: "url", path: resolved.url }); return resolved.url; } diff --git a/src/core/fonts/font-config.ts b/src/core/fonts/font-config.ts index 27c1152..be5a207 100644 --- a/src/core/fonts/font-config.ts +++ b/src/core/fonts/font-config.ts @@ -6,28 +6,17 @@ import { GOOGLE_FONTS_BY_FILENAME, GOOGLE_FONTS_BY_NAME } from "./google-fonts"; const FONT_CDN = "https://templates.shotstack.io/basic/asset/font"; -/** Font family name to file path mapping */ +/** Font family name to file path mapping (variable fonts where available, matching Edit API) */ export const FONT_PATHS: Record = { Arapey: `${FONT_CDN}/arapey-regular.ttf`, "Clear Sans": `${FONT_CDN}/clearsans-regular.ttf`, "Clear Sans Bold": `${FONT_CDN}/clearsans-bold.ttf`, "Didact Gothic": `${FONT_CDN}/didactgothic-regular.ttf`, - Montserrat: `${FONT_CDN}/montserrat-regular.ttf`, - "Montserrat Bold": `${FONT_CDN}/montserrat-bold.ttf`, - "Montserrat ExtraBold": `${FONT_CDN}/montserrat-extrabold.ttf`, - "Montserrat SemiBold": `${FONT_CDN}/montserrat-semibold.ttf`, - "Montserrat Light": `${FONT_CDN}/montserrat-light.ttf`, - "Montserrat Medium": `${FONT_CDN}/montserrat-medium.ttf`, - "Montserrat Black": `${FONT_CDN}/montserrat-black.ttf`, + Montserrat: `${FONT_CDN}/Montserrat.ttf`, MovLette: `${FONT_CDN}/movlette.ttf`, - "Open Sans": `${FONT_CDN}/opensans-regular.ttf`, - "Open Sans Bold": `${FONT_CDN}/opensans-bold.ttf`, - "Open Sans ExtraBold": `${FONT_CDN}/opensans-extrabold.ttf`, + "Open Sans": `${FONT_CDN}/OpenSans.ttf`, "Permanent Marker": `${FONT_CDN}/permanentmarker-regular.ttf`, - Roboto: `${FONT_CDN}/roboto-regular.ttf`, - "Roboto Bold": `${FONT_CDN}/roboto-bold.ttf`, - "Roboto Light": `${FONT_CDN}/roboto-light.ttf`, - "Roboto Medium": `${FONT_CDN}/roboto-medium.ttf`, + Roboto: `${FONT_CDN}/Roboto.ttf`, "Sue Ellen Francisco": `${FONT_CDN}/sueellenfrancisco-regular.ttf`, "Work Sans": `${FONT_CDN}/worksans.ttf` }; @@ -75,32 +64,45 @@ export function parseFontFamily(fontFamily: string): { baseFontFamily: string; f } /** - * Resolve a font family name to its file path + * Resolve a font family name to its file path. + * Priority: Google filename hash → weight-specific built-in → exact match → + * base font (variable) → Google variable → Google by display name. */ -export function resolveFontPath(fontFamily: string): string | undefined { +export function resolveFontPath(fontFamily: string, weight?: number): string | undefined { // Try Google Fonts by filename hash (from FontPicker selection) const googleFontByFilename = GOOGLE_FONTS_BY_FILENAME.get(fontFamily); if (googleFontByFilename) { return googleFontByFilename.url; } - // Try built-in fonts by exact match (e.g., "Montserrat ExtraBold") - if (FONT_PATHS[fontFamily]) { - return FONT_PATHS[fontFamily]; + // Parse family and resolve aliases once (shared by all resolution steps) + const { baseFontFamily } = parseFontFamily(fontFamily); + const resolved = FONT_ALIASES[baseFontFamily] ?? baseFontFamily; + + // Try weight-specific built-in (e.g., "Clear Sans Bold" for weight 700) + if (weight !== undefined && weight !== 400) { + const modifier = Object.entries(WEIGHT_MODIFIERS).find(([, w]) => w === weight)?.[0]; + if (modifier) { + const weightedName = `${resolved} ${modifier}`; + if (FONT_PATHS[weightedName]) return FONT_PATHS[weightedName]; + } } - // Try built-in fonts by alias or base name - const { baseFontFamily } = parseFontFamily(fontFamily); - const resolvedName = FONT_ALIASES[baseFontFamily] ?? baseFontFamily; - if (FONT_PATHS[resolvedName]) { - return FONT_PATHS[resolvedName]; + // Try built-in fonts by exact input (e.g., "Clear Sans Bold" as literal key) + if (FONT_PATHS[fontFamily]) return FONT_PATHS[fontFamily]; + + // Try base font after alias resolution — covers variable fonts (Roboto, Montserrat, etc.) + if (FONT_PATHS[resolved]) return FONT_PATHS[resolved]; + + // Try Google Fonts — prefer variable when weight is specified + if (weight !== undefined) { + const googleFont = GOOGLE_FONTS_BY_NAME.get(resolved); + if (googleFont?.isVariable) return googleFont.url; } - // Fall back to Google Fonts by display name (for fonts not in built-in list) + // Fall back to Google Fonts by display name const googleFontByName = GOOGLE_FONTS_BY_NAME.get(fontFamily); - if (googleFontByName) { - return googleFontByName.url; - } + if (googleFontByName) return googleFontByName.url; return undefined; } diff --git a/src/templates/test.json b/src/templates/test.json index f27f02b..5688fbd 100644 --- a/src/templates/test.json +++ b/src/templates/test.json @@ -3,40 +3,247 @@ "background": "#FFFFFF", "tracks": [ { - "clips": [ - { - "asset": { - "type": "rich-text", - "text": "Hello World", - "font": { - "family": "source" - } - }, - "start": 0, - "length": 5, - "width": 500, - "height": 300, - "offset": { - "x": 0, - "y": 0 - } - } - ] - } - ], - "fonts": [ + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w100", "font": { "family": "Roboto", "weight": 100, "size": 22, "color": "#000088" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": -0.44 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w200", "font": { "family": "Roboto", "weight": 200, "size": 22, "color": "#000088" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": -0.37 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w300 (built-in Light)", "font": { "family": "Roboto", "weight": 300, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": -0.30 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w400 (built-in Regular)", "font": { "family": "Roboto", "weight": 400, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": -0.23 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w500 (built-in Medium)", "font": { "family": "Roboto", "weight": 500, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": -0.16 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w600", "font": { "family": "Roboto", "weight": 600, "size": 22, "color": "#000088" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": -0.09 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w700 (built-in Bold)", "font": { "family": "Roboto", "weight": 700, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": -0.02 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w800", "font": { "family": "Roboto", "weight": 800, "size": 22, "color": "#000088" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": 0.05 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w900 (#76 fix)", "font": { "family": "Roboto", "weight": 900, "size": 22, "color": "#000088" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": 0.12 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w0 (INVALID)", "font": { "family": "Roboto", "weight": 0, "size": 22, "color": "#CC0000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": 0.19 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w450 (INVALID)", "font": { "family": "Roboto", "weight": 450, "size": 22, "color": "#CC0000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": 0.26 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w1200 (INVALID)", "font": { "family": "Roboto", "weight": 1200, "size": 22, "color": "#CC0000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": 0.33 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w-100 (INVALID)", "font": { "family": "Roboto", "weight": -100, "size": 22, "color": "#CC0000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": -0.30, "y": 0.40 } + }] + }, + + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Montserrat w300 (built-in Light)", "font": { "family": "Montserrat", "weight": 300, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": -0.44 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Montserrat w500 (built-in Medium)", "font": { "family": "Montserrat", "weight": 500, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": -0.37 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Montserrat w600 (built-in SemiBold)", "font": { "family": "Montserrat", "weight": 600, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": -0.30 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Montserrat w700 (built-in Bold)", "font": { "family": "Montserrat", "weight": 700, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": -0.23 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Montserrat w800 (built-in ExtraBold)", "font": { "family": "Montserrat", "weight": 800, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": -0.16 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Montserrat w900 (built-in Black)", "font": { "family": "Montserrat", "weight": 900, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": -0.09 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Open Sans w700 (built-in Bold)", "font": { "family": "Open Sans", "weight": 700, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": -0.02 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Open Sans w800 (built-in ExtraBold)", "font": { "family": "Open Sans", "weight": 800, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": 0.05 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Open Sans w900", "font": { "family": "Open Sans", "weight": 900, "size": 22, "color": "#000088" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": 0.12 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Work Sans w400 (CDN variable)", "font": { "family": "Work Sans", "weight": 400, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": 0.19 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Work Sans w700 (CDN variable)", "font": { "family": "Work Sans", "weight": 700, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": 0.26 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Work Sans w900 (CDN variable)", "font": { "family": "Work Sans", "weight": 900, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": 0.33 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Clear Sans w700 (built-in Bold)", "font": { "family": "Clear Sans", "weight": 700, "size": 22, "color": "#000000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0, "y": 0.40 } + }] + }, + + { + "clips": [{ + "asset": { "type": "rich-text", "text": "\"Montserrat Bold\" no weight", "font": { "family": "Montserrat Bold", "size": 22, "color": "#006600" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": -0.44 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "\"Montserrat Bold\" + w300", "font": { "family": "Montserrat Bold", "weight": 300, "size": 22, "color": "#006600" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": -0.37 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "\"Montserrat ExtraBold\" + w900", "font": { "family": "Montserrat ExtraBold", "weight": 900, "size": 22, "color": "#006600" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": -0.30 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "\"OpenSans\" alias w700", "font": { "family": "OpenSans", "weight": 700, "size": 22, "color": "#006600" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": -0.23 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "\"WorkSans\" alias w700", "font": { "family": "WorkSans", "weight": 700, "size": 22, "color": "#006600" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": -0.16 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Arapey w700 (single file)", "font": { "family": "Arapey", "weight": 700, "size": 22, "color": "#006600" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": -0.09 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Permanent Marker w700", "font": { "family": "Permanent Marker", "weight": 700, "size": 22, "color": "#006600" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": -0.02 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Didact Gothic w700", "font": { "family": "Didact Gothic", "weight": 700, "size": 22, "color": "#006600" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": 0.05 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w\"bold\" (string)", "font": { "family": "Roboto", "weight": "bold", "size": 22, "color": "#CC0000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": 0.12 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w\"900\" (string num)", "font": { "family": "Roboto", "weight": "900", "size": 22, "color": "#006600" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": 0.19 } + }] + }, + { + "clips": [{ + "asset": { "type": "rich-text", "text": "Roboto w1000 (INVALID)", "font": { "family": "Roboto", "weight": 1000, "size": 22, "color": "#CC0000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": 0.26 } + }] + }, { - "src": "https://shotstack-ingest-api-v1-sources.s3.ap-southeast-2.amazonaws.com/010uzt2uca/zzz01kh5-3x11z-gyhmj-w7w59-3kb9ng/source.ttf" + "clips": [{ + "asset": { "type": "rich-text", "text": "UnknownFont w400", "font": { "family": "UnknownFont", "weight": 400, "size": 22, "color": "#CC0000" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": 0.33 } + }] }, { - "src": "https://shotstack-ingest-api-v1-sources.s3.ap-southeast-2.amazonaws.com/010uzt2uca/zzz01kh5-6kb4e-ft9gg-xgvsd-96qcr7/source.ttf" + "clips": [{ + "asset": { "type": "rich-text", "text": "No weight property", "font": { "family": "Roboto", "size": 22, "color": "#006600" } }, + "start": 0, "length": 5, "width": 540, "height": 60, "offset": { "x": 0.30, "y": 0.40 } + }] } ] }, "output": { "size": { - "width": 1024, - "height": 576 + "width": 1920, + "height": 1080 }, "format": "mp4", "destinations": [ diff --git a/tests/font-config.test.ts b/tests/font-config.test.ts index b4a0ebd..340db84 100644 --- a/tests/font-config.test.ts +++ b/tests/font-config.test.ts @@ -56,19 +56,20 @@ describe("Font Configuration", () => { describe("built-in font resolution", () => { it("resolves built-in fonts by exact name", () => { - expect(resolveFontPath("Montserrat")).toBe("https://templates.shotstack.io/basic/asset/font/montserrat-regular.ttf"); - expect(resolveFontPath("Open Sans")).toBe("https://templates.shotstack.io/basic/asset/font/opensans-regular.ttf"); - expect(resolveFontPath("Roboto")).toBe("https://templates.shotstack.io/basic/asset/font/roboto-regular.ttf"); + expect(resolveFontPath("Montserrat")).toBe("https://templates.shotstack.io/basic/asset/font/Montserrat.ttf"); + expect(resolveFontPath("Open Sans")).toBe("https://templates.shotstack.io/basic/asset/font/OpenSans.ttf"); + expect(resolveFontPath("Roboto")).toBe("https://templates.shotstack.io/basic/asset/font/Roboto.ttf"); }); - it("resolves built-in fonts with weight suffix", () => { - expect(resolveFontPath("Montserrat Bold")).toBe("https://templates.shotstack.io/basic/asset/font/montserrat-bold.ttf"); - expect(resolveFontPath("Open Sans Bold")).toBe("https://templates.shotstack.io/basic/asset/font/opensans-bold.ttf"); + it("resolves built-in fonts with weight suffix via base name fallback", () => { + // Weight-specific entries removed — "Montserrat Bold" parses to base "Montserrat" → variable font + expect(resolveFontPath("Montserrat Bold")).toBe("https://templates.shotstack.io/basic/asset/font/Montserrat.ttf"); + expect(resolveFontPath("Open Sans Bold")).toBe("https://templates.shotstack.io/basic/asset/font/OpenSans.ttf"); }); it("resolves built-in fonts by alias", () => { // CamelCase aliases should resolve to the canonical font - expect(resolveFontPath("OpenSans")).toBe("https://templates.shotstack.io/basic/asset/font/opensans-regular.ttf"); + expect(resolveFontPath("OpenSans")).toBe("https://templates.shotstack.io/basic/asset/font/OpenSans.ttf"); expect(resolveFontPath("WorkSans")).toBe("https://templates.shotstack.io/basic/asset/font/worksans.ttf"); }); }); @@ -142,6 +143,77 @@ describe("Font Configuration", () => { }); }); +describe("resolveFontPath with weight", () => { + it("resolves variable font for weight 400", () => { + expect(resolveFontPath("Roboto", 400)).toBe("https://templates.shotstack.io/basic/asset/font/Roboto.ttf"); + }); + + it("resolves variable font for all weights (no weight-specific files needed)", () => { + // Variable fonts handle all weights via wght axis — same URL for any weight + expect(resolveFontPath("Roboto", 700)).toBe("https://templates.shotstack.io/basic/asset/font/Roboto.ttf"); + expect(resolveFontPath("Montserrat", 900)).toBe("https://templates.shotstack.io/basic/asset/font/Montserrat.ttf"); + expect(resolveFontPath("Open Sans", 800)).toBe("https://templates.shotstack.io/basic/asset/font/OpenSans.ttf"); + }); + + /** + * REGRESSION TEST for issue #76: Roboto weight 900 must NOT resolve to + * a static font that can't provide weight 900. + * With variable fonts, the base font handles all weights via wght axis. + */ + it("resolves Roboto weight 900 to CDN variable font", () => { + const url = resolveFontPath("Roboto", 900); + expect(url).toBe("https://templates.shotstack.io/basic/asset/font/Roboto.ttf"); + }); + + it("resolves alias-based family names with weight", () => { + expect(resolveFontPath("OpenSans", 700)).toBe("https://templates.shotstack.io/basic/asset/font/OpenSans.ttf"); + }); + + it("falls back to weight-specific built-in for non-variable fonts", () => { + // Clear Sans is static (no variable version) — weight-specific entries still used + expect(resolveFontPath("Clear Sans", 700)).toBe("https://templates.shotstack.io/basic/asset/font/clearsans-bold.ttf"); + }); + + it("resolves Google Fonts by filename hash even with non-default weight", () => { + // FontPicker selection (filename hash) should always resolve regardless of weight + const filenameHash = "QGYsz_wNahGAdqQ43RhPe6rol_lQ4A"; + const url = resolveFontPath(filenameHash, 900); + expect(url).toContain("fonts.gstatic.com"); + }); + + it("returns undefined for completely unknown fonts with weight", () => { + expect(resolveFontPath("NonExistentFont12345", 900)).toBeUndefined(); + }); +}); + +describe("resolveFontPath exact-match priority", () => { + it("exact key takes priority when weight-specific lookup fails", () => { + // "Clear Sans Bold" is an exact FONT_PATHS key. With weight=300, + // no "Clear Sans Light" entry exists, so exact match on input wins. + expect(resolveFontPath("Clear Sans Bold", 300)) + .toBe("https://templates.shotstack.io/basic/asset/font/clearsans-bold.ttf"); + }); + + it("weight-specific and exact match converge for matching weight", () => { + // weight=700 finds "Clear Sans Bold" via modifier AND "Clear Sans Bold" is an exact key + // Both paths arrive at the same result (weight-specific is checked first) + expect(resolveFontPath("Clear Sans Bold", 700)) + .toBe("https://templates.shotstack.io/basic/asset/font/clearsans-bold.ttf"); + }); + + it("preserves exact match on compound font names without weight", () => { + // "Clear Sans Bold" as a literal key, no weight provided + expect(resolveFontPath("Clear Sans Bold")) + .toBe("https://templates.shotstack.io/basic/asset/font/clearsans-bold.ttf"); + }); + + it("variable font base entry wins over removed weight-specific entries", () => { + // "Montserrat Bold" no longer has its own entry — falls through to base "Montserrat" + expect(resolveFontPath("Montserrat Bold", 700)) + .toBe("https://templates.shotstack.io/basic/asset/font/Montserrat.ttf"); + }); +}); + describe("Font Resolution Regression Tests", () => { describe("variable font support", () => { /** diff --git a/tests/rich-text-player-font-caching.test.ts b/tests/rich-text-player-font-caching.test.ts index 2df3c4e..6be04aa 100644 --- a/tests/rich-text-player-font-caching.test.ts +++ b/tests/rich-text-player-font-caching.test.ts @@ -5,7 +5,6 @@ import type { Edit } from "@core/edit-session"; import type { RichTextAsset, ResolvedClip } from "@schemas"; -let mockRegisterFontFromUrl: jest.Mock, [string, { family: string; weight: string }]>; let mockRegisterFontFromFile: jest.Mock, [string, { family: string; weight: string }]>; let mockValidate: jest.Mock<{ value: unknown }, [unknown]>; let mockCreateRenderer: jest.Mock<{ render: jest.Mock, [unknown]> }, [HTMLCanvasElement]>; @@ -132,14 +131,13 @@ jest.mock("@shotstack/shotstack-canvas", () => ({ mockCreateRenderer = jest.fn().mockReturnValue(renderer); mockValidate = jest.fn().mockImplementation((asset: unknown) => ({ value: asset })); mockRenderFrame = jest.fn().mockResolvedValue([]); - mockRegisterFontFromUrl = jest.fn().mockResolvedValue(undefined); mockRegisterFontFromFile = jest.fn().mockResolvedValue(undefined); return { validate: mockValidate, createRenderer: mockCreateRenderer, renderFrame: mockRenderFrame, - registerFontFromUrl: mockRegisterFontFromUrl, + registerFontFromUrl: jest.fn().mockResolvedValue(undefined), registerFontFromFile: mockRegisterFontFromFile, destroy: jest.fn() }; @@ -241,6 +239,7 @@ describe("RichTextPlayer font caching", () => { } }); (RichTextPlayer as unknown as { fontCapabilityCache: Map> }).fontCapabilityCache.clear(); + (RichTextPlayer as unknown as { fontBytesCache: Map> }).fontBytesCache.clear(); }); it("deduplicates registration across repeated reconfigure calls for the same font key", async () => { @@ -250,19 +249,19 @@ describe("RichTextPlayer font caching", () => { await reconfigure(player, asset); await reconfigure(player, asset); - expect(mockRegisterFontFromUrl).toHaveBeenCalledTimes(1); + expect(mockRegisterFontFromFile).toHaveBeenCalledTimes(1); }); it("deduplicates in-flight concurrent reconfigure registration calls", async () => { const { player, asset } = await createReadyPlayer(); const deferred = createDeferred(); - mockRegisterFontFromUrl.mockImplementationOnce(async () => deferred.promise); + mockRegisterFontFromFile.mockImplementationOnce(async () => deferred.promise); const first = reconfigure(player, asset); const second = reconfigure(player, asset); await Promise.resolve(); - expect(mockRegisterFontFromUrl).toHaveBeenCalledTimes(1); + expect(mockRegisterFontFromFile).toHaveBeenCalledTimes(1); deferred.resolve(undefined); await Promise.all([first, second]); @@ -281,12 +280,12 @@ describe("RichTextPlayer font caching", () => { it("caches failed registration results and does not retry on subsequent reconfigure", async () => { const { player, asset } = await createReadyPlayer(); - mockRegisterFontFromUrl.mockRejectedValueOnce(new Error("registration failed")); + mockRegisterFontFromFile.mockRejectedValueOnce(new Error("registration failed")); await reconfigure(player, asset); await reconfigure(player, asset); - expect(mockRegisterFontFromUrl).toHaveBeenCalledTimes(1); + expect(mockRegisterFontFromFile).toHaveBeenCalledTimes(1); }); it("caches failed capability checks and does not refetch on subsequent reconfigure", async () => { @@ -311,10 +310,100 @@ describe("RichTextPlayer font caching", () => { await reconfigure(player, regular); await reconfigure(player, serif); - expect(mockRegisterFontFromUrl).toHaveBeenCalledTimes(2); + expect(mockRegisterFontFromFile).toHaveBeenCalledTimes(2); expect(mockFetch).toHaveBeenCalledTimes(2); }); + it("evicts failed font bytes from cache allowing retry on subsequent load", async () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(); + const fontUrl = "https://cdn.test/fresh.ttf"; + const edit = createMockEdit({ "Fresh Font|400": fontUrl }); + const asset = { + type: "rich-text", + text: "Hello", + font: { family: "Fresh Font", weight: 400, size: 48, color: "#fff" } + } as unknown as RichTextAsset; + + // Fail ALL fetches for the first player + mockFetch.mockRejectedValue(new Error("network error")); + const player1 = new RichTextPlayer(edit, createClip(asset)); + await player1.load(); + + const failedFetchCount = mockFetch.mock.calls.length; + + // Restore fetch to succeed and clear capability cache (also cached the failure) + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(64)) + }); + (RichTextPlayer as unknown as { fontCapabilityCache: Map> }).fontCapabilityCache.clear(); + + // Second player with same font — should retry fetch, not get cached rejection + const player2 = new RichTextPlayer(edit, createClip(asset)); + await player2.load(); + + expect(mockFetch.mock.calls.length).toBeGreaterThan(failedFetchCount); + warnSpy.mockRestore(); + }); + + it("resolves string weight 'bold' to numeric 700 for font registration", async () => { + const edit = createMockEdit({ + "Source Sans|400": "https://cdn.test/source-regular.ttf", + "Source Sans|700": "https://cdn.test/source-bold.ttf" + }); + const initialAsset = createAsset(400, "Source Sans"); + const player = new RichTextPlayer(edit, createClip(initialAsset)); + await player.load(); + + const boldAsset = { + type: "rich-text", + text: "Bold text", + font: { family: "Source Sans", weight: "bold", size: 48, color: "#ffffff" } + } as unknown as RichTextAsset; + await reconfigure(player, boldAsset); + + const { calls } = mockRegisterFontFromFile.mock; + const boldCall = calls.find(([, desc]: [unknown, { weight: string }]) => desc.weight === "700"); + expect(boldCall).toBeDefined(); + }); + + it("resolves string weight '900' to numeric 900 for font registration", async () => { + const edit = createMockEdit({ + "Source Sans|400": "https://cdn.test/source-regular.ttf", + "Source Sans|900": "https://cdn.test/source-black.ttf" + }); + const initialAsset = createAsset(400, "Source Sans"); + const player = new RichTextPlayer(edit, createClip(initialAsset)); + await player.load(); + + const blackAsset = { + type: "rich-text", + text: "Black text", + font: { family: "Source Sans", weight: "900", size: 48, color: "#ffffff" } + } as unknown as RichTextAsset; + await reconfigure(player, blackAsset); + + const { calls } = mockRegisterFontFromFile.mock; + const blackCall = calls.find(([, desc]: [unknown, { weight: string }]) => desc.weight === "900"); + expect(blackCall).toBeDefined(); + }); + + it("warns on unrecognized string weight and falls back to parsed weight", async () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(); + const { player } = await createReadyPlayer(); + + const garbageAsset = { + type: "rich-text", + text: "Bad weight", + font: { family: "Source Sans", weight: "garbage", size: 48, color: "#ffffff" } + } as unknown as RichTextAsset; + await reconfigure(player, garbageAsset); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unrecognized font weight "garbage"')); + warnSpy.mockRestore(); + }); + it("deduplicates registration and capability checks when URL query/hash changes for the same font", async () => { const edit = createMockEdit({ "Source Sans|400": "https://cdn.test/source.ttf?token=first" @@ -328,7 +417,7 @@ describe("RichTextPlayer font caching", () => { await reconfigure(player, asset); await reconfigure(player, asset); - expect(mockRegisterFontFromUrl).toHaveBeenCalledTimes(1); + expect(mockRegisterFontFromFile).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledTimes(1); }); });