Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 50 additions & 24 deletions src/components/canvas/players/rich-text-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,20 @@ type TextEngine = Awaited<ReturnType<typeof createTextEngine>>;

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<string, number> = {
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<string, Promise<boolean>>();
private static readonly fontBytesCache = new Map<string, Promise<ArrayBuffer>>();
private textEngine: TextEngine | null = null;
private renderer: ReturnType<TextEngine["createRenderer"]> | null = null;
private canvas: HTMLCanvasElement | null = null;
Expand All @@ -36,6 +49,22 @@ export class RichTextPlayer extends Player {
return withoutHash.split("?", 1)[0];
}

private static fetchFontBytes(url: string): Promise<ArrayBuffer> {
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
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand All @@ -152,11 +183,7 @@ export class RichTextPlayer extends Player {
private createFontCapabilityCheckPromise(fontUrl: string): Promise<boolean> {
return (async (): Promise<boolean> => {
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
Expand Down Expand Up @@ -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)
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
60 changes: 31 additions & 29 deletions src/core/fonts/font-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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`
};
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading